diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..6002af7 --- /dev/null +++ b/.codex/environments/environment.toml @@ -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" diff --git a/SiriRemoteCore/Sources/SiriRemoteCore/Action.swift b/SiriRemoteCore/Sources/SiriRemoteCore/Action.swift index 8523b6e..3f59754 100644 --- a/SiriRemoteCore/Sources/SiriRemoteCore/Action.swift +++ b/SiriRemoteCore/Sources/SiriRemoteCore/Action.swift @@ -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 @@ -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) @@ -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)) @@ -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) diff --git a/SiriRemoteCore/Sources/SiriRemoteCore/Config.swift b/SiriRemoteCore/Sources/SiriRemoteCore/Config.swift index a72a4fe..2553dc9 100644 --- a/SiriRemoteCore/Sources/SiriRemoteCore/Config.swift +++ b/SiriRemoteCore/Sources/SiriRemoteCore/Config.swift @@ -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 (`.hold`), // stage 2 = holdThreshold2 (`.hold2`), stage 3 = holdThreshold3 (`.hold3`). @@ -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. @@ -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 { @@ -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 @@ -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 } } @@ -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) @@ -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) } } diff --git a/SiriRemoteCore/Sources/SiriRemoteCore/Controller.swift b/SiriRemoteCore/Sources/SiriRemoteCore/Controller.swift index 1c4cf2e..df49346 100644 --- a/SiriRemoteCore/Sources/SiriRemoteCore/Controller.swift +++ b/SiriRemoteCore/Sources/SiriRemoteCore/Controller.swift @@ -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 `".K"` looked up in the @@ -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 { @@ -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), @@ -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) diff --git a/SiriRemoteCore/Tests/SiriRemoteCoreTests/ConfigWriterTests.swift b/SiriRemoteCore/Tests/SiriRemoteCoreTests/ConfigWriterTests.swift index c7aba89..c68ada4 100644 --- a/SiriRemoteCore/Tests/SiriRemoteCoreTests/ConfigWriterTests.swift +++ b/SiriRemoteCore/Tests/SiriRemoteCoreTests/ConfigWriterTests.swift @@ -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"), @@ -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 diff --git a/SiriRemoteCore/Tests/SiriRemoteCoreTests/ControllerTests.swift b/SiriRemoteCore/Tests/SiriRemoteCoreTests/ControllerTests.swift index de4af4b..475f667 100644 --- a/SiriRemoteCore/Tests/SiriRemoteCoreTests/ControllerTests.swift +++ b/SiriRemoteCore/Tests/SiriRemoteCoreTests/ControllerTests.swift @@ -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 = """ diff --git a/app/ActionVisual.swift b/app/ActionVisual.swift index 5500a23..3aa074b 100644 --- a/app/ActionVisual.swift +++ b/app/ActionVisual.swift @@ -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" diff --git a/app/KeyMap.swift b/app/KeyMap.swift index 3fe6f25..707c021 100644 --- a/app/KeyMap.swift +++ b/app/KeyMap.swift @@ -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) } @@ -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, @@ -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, + ] } diff --git a/app/LayoutView.swift b/app/LayoutView.swift index 9597505..e235607 100644 --- a/app/LayoutView.swift +++ b/app/LayoutView.swift @@ -15,6 +15,9 @@ import UniformTypeIdentifiers struct LayoutView: View { let config: Config + /// A process-local feature reservation. The saved mapping remains visible underneath but its + /// exact slot is locked while the reservation is active. + var reservedEventKey: String? = nil /// Persist an edited config (writes config.jsonc → hot-reloads). Nil = read-only (snapshots). var onSave: ((Config) -> Void)? = nil @@ -57,8 +60,11 @@ struct LayoutView: View { /// ImageRenderer snapshots (a ScrollView measures as empty when rendered headless). var scrolls: Bool = true - init(config: Config, onSave: ((Config) -> Void)? = nil, scrolls: Bool = true, initialSelected: String? = nil) { + init(config: Config, reservedEventKey: String? = nil, + onSave: ((Config) -> Void)? = nil, scrolls: Bool = true, + initialSelected: String? = nil) { self.config = config + self.reservedEventKey = reservedEventKey self.onSave = onSave self.scrolls = scrolls _selectedKey = State(initialValue: initialSelected) @@ -438,7 +444,7 @@ struct LayoutView: View { InputRow(key: "ring.left", name: "Ring ←"), InputRow(key: "ring.right", name: "Ring →"), InputRow(key: "select", name: "Center click"), - InputRow(key: "touch", name: "Touch surface"), + InputRow(key: "touch", name: "Touch movement"), ]), InputGroup(name: "Buttons", rows: [ InputRow(key: "button.siri", name: "Siri / voice"), @@ -452,6 +458,7 @@ struct LayoutView: View { InputRow(key: "button.power", name: "Power"), ]), InputGroup(name: "Gestures", rows: [ + InputRow(key: "tap.one", name: "Light touch tap"), InputRow(key: "swipe.up", name: "Swipe ↑"), InputRow(key: "swipe.down", name: "Swipe ↓"), InputRow(key: "swipe.left", name: "Swipe ←"), @@ -464,7 +471,8 @@ struct LayoutView: View { private static func nativeLabel(_ key: String) -> String { switch key { case "select": return "Click" - case "touch": return "Move · Scroll · Swipe" + case "touch": return "Configure in Tuning" + case "tap.one": return "Click" case "button.siri": return "Siri" case "button.playPause": return "Play / Pause" case "button.mute": return "Mute" @@ -538,7 +546,7 @@ struct LayoutView: View { private struct Slot { let slotKey: String; let label: String } private func slots(for base: String) -> [Slot] { - if base.hasPrefix("ring.") || base.hasPrefix("button.") { + if base.hasPrefix("ring.") || base.hasPrefix("button.") || base == "select" { return [ Slot(slotKey: base, label: "Tap"), Slot(slotKey: base + ".double", label: "Double-tap"), @@ -552,7 +560,7 @@ struct LayoutView: View { ] } // Swipes / two-finger tap are one-shot gesture events — a single action, no hold/double. - if base.hasPrefix("swipe.") || base == "tap.two" { + if base.hasPrefix("swipe.") || base == "tap.one" || base == "tap.two" { return [Slot(slotKey: base, label: "Action")] } return [] @@ -598,6 +606,7 @@ struct LayoutView: View { } else { ForEach(Array(theSlots.enumerated()), id: \.element.slotKey) { idx, slot in if idx > 0 { Divider() } + let reserved = reservedEventKey == slot.slotKey HStack(spacing: 12) { Text(slot.label).font(.system(size: 13, weight: .medium)) .frame(width: 92, alignment: .leading) @@ -607,8 +616,19 @@ struct LayoutView: View { onChange: { saveSlot(slot.slotKey, $0) } ) .id("\(mode)/\(editLayer ?? "-")/\(slot.slotKey)") + .disabled(reserved) + .opacity(reserved ? 0.42 : 1) + if reserved { + Label("Used by Touch switch", systemImage: "lock.fill") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.secondary) + } Spacer(minLength: 0) } + .contentShape(Rectangle()) + .help(reserved + ? "This action is occupied by Touch Surface mode switching. Disable it or choose Release in Tuning → Touch Surface to restore this mapping." + : "") .padding(.horizontal, 16).padding(.vertical, 9) } } @@ -627,7 +647,8 @@ private struct ActionSlotEditor: View { let onChange: (Action?) -> Void enum Kind: String, CaseIterable, Identifiable { - case none = "None", keystroke = "Keystroke", pushToTalk = "Push to talk", + case none = "Inherit / System", disabled = "Disabled", + keystroke = "Keystroke", pushToTalk = "Push to talk", media = "Media", mouse = "Mouse", launchApp = "Launch app", openURL = "Open URL", shell = "Shell", applescript = "AppleScript", space = "Switch space", brightness = "Brightness", @@ -662,6 +683,7 @@ private struct ActionSlotEditor: View { if newKind == .none || build() != nil { commit() } })) { Text(Kind.none.rawValue).tag(Kind.none) + Text(Kind.disabled.rawValue).tag(Kind.disabled) Section("Keys & media") { Text(Kind.keystroke.rawValue).tag(Kind.keystroke) Text(Kind.pushToTalk.rawValue).tag(Kind.pushToTalk) @@ -696,7 +718,9 @@ private struct ActionSlotEditor: View { @ViewBuilder private var param: some View { switch kind { case .none: - Text("does nothing").foregroundStyle(.secondary).font(.system(size: 12)) + Text("remove this override and fall back").foregroundStyle(.secondary).font(.system(size: 12)) + case .disabled: + Text("consume this input without an action").foregroundStyle(.secondary).font(.system(size: 12)) case .fullscreen: Text("toggles the frontmost window").foregroundStyle(.secondary).font(.system(size: 12)) case .minimize: @@ -706,12 +730,12 @@ private struct ActionSlotEditor: View { case .appWheel: Text("opens the radial launcher (settings.appWheel)").foregroundStyle(.secondary).font(.system(size: 12)) case .keystroke, .repeatKey: - TextField("cmd+shift+t", text: $text).textFieldStyle(.roundedBorder).frame(width: 170) - .focused($focused).onSubmit(commit) + ShortcutRecorder(value: $text, onRecord: commitRecordedShortcut) + .frame(width: 190, height: 24) case .pushToTalk: HStack(spacing: 8) { - TextField("cmd+shift+t", text: $text).textFieldStyle(.roundedBorder).frame(width: 170) - .focused($focused).onSubmit(commit) + ShortcutRecorder(value: $text, onRecord: commitRecordedShortcut) + .frame(width: 190, height: 24) Text("fires on press AND on release") .font(.system(size: 10)).foregroundStyle(.secondary) } @@ -758,6 +782,7 @@ private struct ActionSlotEditor: View { private func load() { guard let a = action else { kind = .none; return } switch a { + case .disabled: kind = .disabled case .keystroke(let k): kind = .keystroke; text = k case .pushToTalk(let k): kind = .pushToTalk; text = k case .media(let k): kind = .media; pick = k @@ -784,6 +809,7 @@ private struct ActionSlotEditor: View { private func resetParamsForKind() { text = ""; altLaunch = ""; repDelay = 0.3; repInterval = 0.045; value = 0 switch kind { + case .disabled: pick = "" case .media: pick = "playpause" case .mouse: pick = "click" case .space: pick = "left" @@ -794,12 +820,19 @@ private struct ActionSlotEditor: View { private func commit() { onChange(build()) } - private func build() -> Action? { + private func commitRecordedShortcut(_ shortcut: String) { + text = shortcut + onChange(build(textOverride: shortcut)) + } + + private func build(textOverride: String? = nil) -> Action? { + let entered = textOverride ?? text switch kind { case .none: return nil - case .keystroke: return text.isEmpty ? nil : .keystroke(keys: text) - case .pushToTalk: return text.isEmpty ? nil : .pushToTalk(keys: text) - case .repeatKey: return text.isEmpty ? nil : .repeatKey(keys: text, delay: repDelay, interval: repInterval) + case .disabled: return .disabled + case .keystroke: return entered.isEmpty ? nil : .keystroke(keys: entered) + case .pushToTalk: return entered.isEmpty ? nil : .pushToTalk(keys: entered) + case .repeatKey: return entered.isEmpty ? nil : .repeatKey(keys: entered, delay: repDelay, interval: repInterval) case .media: return .media(key: pick.isEmpty ? "playpause" : pick) case .mouse: return .mouse(op: pick.isEmpty ? "click" : pick) case .launchApp: return text.isEmpty ? nil : .launch(app: text, url: altLaunch.isEmpty ? nil : altLaunch) diff --git a/app/MacActionExecutor.swift b/app/MacActionExecutor.swift index 4e39fe4..607659c 100644 --- a/app/MacActionExecutor.swift +++ b/app/MacActionExecutor.swift @@ -21,6 +21,8 @@ final class MacActionExecutor: ActionExecutor { func execute(_ action: Action, payload: EventPayload?) { rmDebug("⚙️ action: \(action)") switch action { + case .disabled: + break case .keystroke(let keys): Keys.synthesize(keys) case .media(let key): diff --git a/app/MenuBarManager.swift b/app/MenuBarManager.swift index c967a96..c8a73e0 100644 --- a/app/MenuBarManager.swift +++ b/app/MenuBarManager.swift @@ -35,6 +35,8 @@ final class MenuBarManager { /// Set by the AppDelegate to open the SwiftUI settings window. var onOpenSettings: (() -> Void)? + /// Opens the launch-time readiness scan again without requiring a relaunch. + var onOpenSetup: (() -> Void)? init(statusItem: NSStatusItem) { self.statusItem = statusItem @@ -102,6 +104,14 @@ final class MenuBarManager { menu.addItem(statusMenuItem) menu.addItem(.separator()) + let setupItem = NSMenuItem( + title: "Setup & Permissions…", + action: #selector(openSetup), + keyEquivalent: "" + ) + setupItem.target = self + menu.addItem(setupItem) + let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",") settingsItem.target = self menu.addItem(settingsItem) @@ -113,6 +123,7 @@ final class MenuBarManager { } @objc private func openSettings() { onOpenSettings?() } + @objc private func openSetup() { onOpenSetup?() } func updateConnectionStatus(connected: Bool) { DispatchQueue.main.async { [weak self] in diff --git a/app/PacketLoggerDropTarget.swift b/app/PacketLoggerDropTarget.swift new file mode 100644 index 0000000..818261d --- /dev/null +++ b/app/PacketLoggerDropTarget.swift @@ -0,0 +1,86 @@ +// +// PacketLoggerDropTarget.swift +// HyperVibe +// +// Narrow AppKit bridge for reliable Finder/Disk Image .app bundle drops. +// + +import AppKit +import SwiftUI + +struct PacketLoggerDropTarget: NSViewRepresentable { + @Binding var isTargeted: Bool + let onDrop: (URL) -> Void + + func makeNSView(context: Context) -> DropReceiverView { + let view = DropReceiverView() + view.onTargetChange = { targeted in + DispatchQueue.main.async { isTargeted = targeted } + } + view.onDrop = { url in + DispatchQueue.main.async { onDrop(url) } + } + return view + } + + func updateNSView(_ nsView: DropReceiverView, context: Context) { + nsView.onTargetChange = { targeted in + DispatchQueue.main.async { isTargeted = targeted } + } + nsView.onDrop = { url in + DispatchQueue.main.async { onDrop(url) } + } + } +} + +final class DropReceiverView: NSView { + var onTargetChange: ((Bool) -> Void)? + var onDrop: ((URL) -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL]) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + registerForDraggedTypes([.fileURL]) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + guard firstFileURL(from: sender) != nil else { return [] } + onTargetChange?(true) + // Accept the drop first, then give a precise in-app explanation if the file is not + // PacketLogger.app. Silently showing a forbidden cursor teaches the user nothing. + return .copy + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + firstFileURL(from: sender) == nil ? [] : .copy + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + onTargetChange?(false) + } + + override func draggingEnded(_ sender: NSDraggingInfo) { + onTargetChange?(false) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + defer { onTargetChange?(false) } + guard let url = firstFileURL(from: sender) else { return false } + onDrop?(url) + return true + } + + private func firstFileURL(from sender: NSDraggingInfo) -> URL? { + guard let value = sender.draggingPasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + )?.first as? URL else { + return nil + } + return value + } +} diff --git a/app/PacketLoggerGuideWindow.swift b/app/PacketLoggerGuideWindow.swift new file mode 100644 index 0000000..7c4503e --- /dev/null +++ b/app/PacketLoggerGuideWindow.swift @@ -0,0 +1,699 @@ +// +// PacketLoggerGuideWindow.swift +// HyperVibe +// +// A floating, persistent walkthrough that stays visible while the user works through Apple's +// developer download page. PacketLogger cannot be redistributed publicly, so this is the one +// setup step the app must guide rather than perform. +// + +import AppKit +import SwiftUI + +@MainActor +final class PacketLoggerGuideWindowController { + private var panel: NSPanel? + private let model: SetupStatusModel + + init(model: SetupStatusModel) { + self.model = model + } + + func show() { + if panel == nil { + let hosting = NSHostingController( + rootView: PacketLoggerGuideView( + model: model, + onOpenDownload: Self.openDownloadPage, + onClose: { [weak self] in self?.panel?.close() } + ) + ) + let win = NSPanel(contentViewController: hosting) + win.title = "获取 Apple PacketLogger" + win.styleMask = [.titled, .closable, .utilityWindow, .fullSizeContentView] + win.titlebarAppearsTransparent = true + win.titleVisibility = .hidden + win.isMovableByWindowBackground = true + win.isReleasedWhenClosed = false + + // This is the narrow AppKit boundary SwiftUI cannot express: keep the instructions + // visible over the browser and across Spaces until the user closes them. + win.isFloatingPanel = true + win.level = .floating + win.hidesOnDeactivate = false + win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + win.contentMinSize = NSSize(width: 420, height: 620) + win.setContentSize(NSSize(width: 440, height: 700)) + win.center() + panel = win + } + + model.refresh() + panel?.makeKeyAndOrderFront(nil) + } + + private static func openDownloadPage() { + guard let url = URL( + string: "https://developer.apple.com/download/all/?q=Additional+Tools+for+Xcode" + ) else { return } + NSWorkspace.shared.open(url) + } +} + +@MainActor +private final class PacketLoggerDownloadWatcher: ObservableObject { + enum Discovery: Equatable { + case none + case diskImage(URL) + case packetLogger(URL) + case wrongDiskImage(URL) + } + + @Published private(set) var discovery: Discovery = .none + @Published private(set) var animationToken = 0 + + private var timer: Timer? + + func start() { + scan() + guard timer == nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.scan() } + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + private func scan() { + let next = Self.findDownloadedMaterial() + guard next != discovery else { return } + discovery = next + if next != .none { + animationToken += 1 + } + } + + private static func findDownloadedMaterial() -> Discovery { + let fm = FileManager.default + + // Once the DMG is open, this is the exact app the user needs to drag. Check this first + // because it is a stronger, more actionable signal than merely seeing a downloaded DMG. + let volumes = testableDirectory( + infoKey: "HyperVibeVolumesDirectory", + defaultPath: "/Volumes" + ) + if let roots = try? fm.contentsOfDirectory( + at: volumes, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { + for root in roots { + let candidate = root + .appendingPathComponent("Hardware", isDirectory: true) + .appendingPathComponent("PacketLogger.app", isDirectory: true) + if isValidPacketLogger(at: candidate) { + return .packetLogger(candidate) + } + } + + // Warn only for names that strongly suggest the user opened a neighboring Xcode + // download by mistake. Never complain about unrelated mounted disks. + if let wrong = roots.first(where: { isLikelyWrongXcodeDownloadName($0.lastPathComponent) }) { + return .wrongDiskImage(wrong) + } + } + + // Apple normally names the file "Additional_Tools_for_Xcode_….dmg". A shallow Downloads + // scan is enough and avoids watching or indexing any unrelated file contents. + let downloads = testableDirectory( + infoKey: "HyperVibeDownloadsDirectory", + defaultPath: fm.homeDirectoryForCurrentUser + .appendingPathComponent("Downloads", isDirectory: true) + .path + ) + if let entries = try? fm.contentsOfDirectory( + at: downloads, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) { + let matching = entries.filter { url in + guard url.pathExtension.lowercased() == "dmg" else { return false } + let name = url.deletingPathExtension().lastPathComponent + .lowercased() + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + return name.contains("additional") + && name.contains("tools") + && name.contains("xcode") + } + if let newest = matching.max(by: { + let lhs = (try? $0.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate) ?? .distantPast + let rhs = (try? $1.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate) ?? .distantPast + return lhs < rhs + }) { + return .diskImage(newest) + } + + if let wrong = entries.first(where: { url in + url.pathExtension.lowercased() == "dmg" + && isLikelyWrongXcodeDownloadName( + url.deletingPathExtension().lastPathComponent + ) + }) { + return .wrongDiskImage(wrong) + } + } + + return .none + } + + static func isValidPacketLogger(at url: URL) -> Bool { + guard url.lastPathComponent.caseInsensitiveCompare("PacketLogger.app") == .orderedSame else { + return false + } + return FileManager.default.isExecutableFile( + atPath: url + .appendingPathComponent("Contents/Resources/packetlogger") + .path + ) + } + + static func resolvingFinderAlias(_ url: URL) -> URL { + guard let values = try? url.resourceValues(forKeys: [.isAliasFileKey]), + values.isAliasFile == true, + let resolved = try? URL( + resolvingAliasFileAt: url, + options: [.withoutUI] + ) else { + return url + } + return resolved + } + + private static func isLikelyWrongXcodeDownloadName(_ rawName: String) -> Bool { + let name = rawName + .lowercased() + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + if name.contains("additional") && name.contains("tools") { + return false + } + return name.contains("command line tools") + || name == "xcode" + || name.hasPrefix("xcode ") + } + + private static func testableDirectory(infoKey: String, defaultPath: String) -> URL { + if let override = Bundle.main.object(forInfoDictionaryKey: infoKey) as? String, + !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return URL(fileURLWithPath: defaultPath, isDirectory: true) + } +} + +private struct AdditionalToolsRecommendation { + let macOSVersion: String + let highlightedVersion: String + let searchTerm: String + let explanation: String + + static var current: AdditionalToolsRecommendation { + let version = ProcessInfo.processInfo.operatingSystemVersion + let readable = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + + // Apple lists Xcode 26.6 as compatible with macOS Tahoe 26.2 through 26.x. PacketLogger + // from the matching Additional Tools 26.6 package is the pipeline validated by this repo. + if version.majorVersion == 26 && version.minorVersion >= 2 { + return AdditionalToolsRecommendation( + macOSVersion: readable, + highlightedVersion: "26.6", + searchTerm: "Additional Tools for Xcode 26.6", + explanation: "你的系统属于 macOS 26.x,优先下载稳定版 26.6;不要选 27 beta。" + ) + } + + // For other supported systems do not fabricate a patch-level match. The Apple downloads + // page and its compatibility notes remain the source of truth. + return AdditionalToolsRecommendation( + macOSVersion: readable, + highlightedVersion: "兼容的最新稳定版", + searchTerm: "Additional Tools for Xcode", + explanation: "选择 Apple 标注为兼容当前 macOS 的最新稳定版,不要选择 beta。" + ) + } +} + +private struct PacketLoggerGuideView: View { + @ObservedObject var model: SetupStatusModel + let onOpenDownload: () -> Void + let onClose: () -> Void + + @StateObject private var downloadWatcher = PacketLoggerDownloadWatcher() + @State private var copied = false + @State private var isDropTargeted = false + @State private var dropScale: CGFloat = 1 + @State private var dropRotation: Double = 0 + @State private var dropMessage: String? + @State private var dropError = false + @State private var isCopying = false + + private let recommendation = AdditionalToolsRecommendation.current + + private var searchTerm: String { recommendation.searchTerm } + + private var packetLoggerReady: Bool { + if hasDestinationOverride { + return PacketLoggerDownloadWatcher.isValidPacketLogger(at: packetLoggerDestination) + } + return model.items.first(where: { $0.requirement == .packetLogger })?.isComplete == true + } + + private var hasDestinationOverride: Bool { + Bundle.main.object( + forInfoDictionaryKey: "HyperVibePacketLoggerInstallDestination" + ) as? String != nil + } + + /// Production always installs to /Applications. A private Info.plist override lets UI tests + /// exercise a real Finder drop into a temporary directory without touching the live system. + private var packetLoggerDestination: URL { + if let override = Bundle.main.object( + forInfoDictionaryKey: "HyperVibePacketLoggerInstallDestination" + ) as? String, !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return URL(fileURLWithPath: "/Applications/PacketLogger.app", isDirectory: true) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + step( + number: 1, + title: "登录 Apple 下载页", + detail: "使用 Apple ID 登录。这个工具来自 Apple,不是第三方下载。" + ) + + VStack(alignment: .leading, spacing: 9) { + stepHeading(number: 2, title: "复制下面这句话并搜索") + HStack(spacing: 8) { + Text(searchTerm) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + Spacer(minLength: 4) + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(searchTerm, forType: .string) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { + copied = false + } + } label: { + Label( + copied ? "已复制" : "复制", + systemImage: copied ? "checkmark" : "doc.on.doc" + ) + } + .controlSize(.small) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color.secondary.opacity(0.09)) + ) + } + + VStack(alignment: .leading, spacing: 9) { + stepHeading(number: 3, title: "下载高亮的版本") + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("这台 Mac") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + Text("macOS \(recommendation.macOSVersion)") + .font(.system(size: 11, weight: .medium)) + } + Spacer() + Image(systemName: "arrow.right") + .foregroundStyle(.secondary) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("选择") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + Text("Additional Tools \(recommendation.highlightedVersion)") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.tint) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color.accentColor.opacity(0.10)) + ) + Text(recommendation.explanation) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 31) + } + + step( + number: 4, + title: "只安装 PacketLogger", + detail: "打开下载好的 DMG,进入 Hardware 文件夹。只把 PacketLogger.app 拖到下面的“应用程序”快捷区;其它工具不用安装。" + ) + + if let notice = discoveryNotice { + HStack(alignment: .top, spacing: 9) { + Image(systemName: notice.symbol) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(notice.color) + VStack(alignment: .leading, spacing: 3) { + Text(notice.title) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(notice.color) + Text(notice.detail) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(notice.color.opacity(0.10)) + ) + } + + applicationsDropArea + } + .padding(20) + } + + Divider() + bottomBar + } + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 700) + .onAppear { downloadWatcher.start() } + .onDisappear { downloadWatcher.stop() } + .onChange(of: downloadWatcher.animationToken) { _ in + emphasizeDropTarget() + } + } + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "waveform.badge.magnifyingglass") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.tint) + .frame(width: 42, height: 42) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(Color.accentColor.opacity(0.14)) + ) + + VStack(alignment: .leading, spacing: 2) { + Text("获取 Apple PacketLogger") + .font(.system(size: 18, weight: .semibold)) + Label("教程会保持置顶", systemImage: "pin.fill") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(.bar) + } + + private func step(number: Int, title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + stepHeading(number: number, title: title) + Text(detail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 31) + } + } + + private func stepHeading(number: Int, title: String) -> some View { + HStack(spacing: 9) { + Text("\(number)") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Circle().fill(Color.accentColor)) + Text(title) + .font(.system(size: 13, weight: .semibold)) + } + } + + private var applicationsDropArea: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(packetLoggerReady ? "已安装" : "拖到这里") + .font(.system(size: 12, weight: .semibold)) + Spacer() + Button("在 Finder 中打开“应用程序”") { + NSWorkspace.shared.open( + URL(fileURLWithPath: "/Applications", isDirectory: true) + ) + } + .buttonStyle(.link) + .font(.system(size: 10)) + } + + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(dropAreaFill) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + isDropTargeted ? Color.accentColor : dropAreaStroke, + style: StrokeStyle( + lineWidth: isDropTargeted ? 2 : 1, + dash: packetLoggerReady ? [] : [6, 4] + ) + ) + + HStack(spacing: 15) { + ZStack(alignment: .bottomTrailing) { + Image(nsImage: NSWorkspace.shared.icon(forFile: "/Applications")) + .resizable() + .frame(width: 56, height: 56) + Image(systemName: packetLoggerReady + ? "checkmark.circle.fill" + : "arrow.down.circle.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(packetLoggerReady ? Color.green : Color.accentColor) + .background(Circle().fill(Color(nsColor: .windowBackgroundColor))) + } + + VStack(alignment: .leading, spacing: 4) { + Text(packetLoggerReady + ? "PacketLogger 已在应用程序中" + : "把 PacketLogger.app 拖到这里") + .font(.system(size: 13, weight: .semibold)) + Text(dropInstruction) + .font(.system(size: 10.5)) + .foregroundStyle(dropError ? Color.red : Color.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(14) + .allowsHitTesting(false) + + if !packetLoggerReady { + PacketLoggerDropTarget( + isTargeted: $isDropTargeted, + onDrop: installDroppedPacketLogger + ) + } + } + .frame(height: 94) + .scaleEffect(dropScale) + .rotationEffect(.degrees(dropRotation)) + } + } + + private var dropAreaFill: Color { + if packetLoggerReady { return Color.green.opacity(0.09) } + if isDropTargeted { return Color.accentColor.opacity(0.15) } + return Color.secondary.opacity(0.08) + } + + private var dropAreaStroke: Color { + if packetLoggerReady { return Color.green.opacity(0.5) } + if dropError || discoveryIsWrong { return Color.red.opacity(0.65) } + return Color.secondary.opacity(0.35) + } + + private var discoveryIsWrong: Bool { + if case .wrongDiskImage = downloadWatcher.discovery { return true } + return false + } + + private var discoveryNotice: ( + symbol: String, + title: String, + detail: String, + color: Color + )? { + switch downloadWatcher.discovery { + case .none: + return nil + case .diskImage(let url): + return ( + "arrow.down.circle.fill", + "下载已完成", + "已找到 \(url.lastPathComponent)。双击打开它,然后进入 Hardware 文件夹。", + .blue + ) + case .packetLogger(let url): + return ( + "checkmark.circle.fill", + "正确的磁盘镜像已打开", + "已找到 \(url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent) → Hardware → PacketLogger.app。把 PacketLogger.app 拖到下面。", + .green + ) + case .wrongDiskImage(let url): + return ( + "xmark.octagon.fill", + "这个磁盘镜像不对", + "检测到 \(url.lastPathComponent)。需要的是 \(recommendation.searchTerm),不是 Xcode 或 Command Line Tools。", + .red + ) + } + } + + private var dropInstruction: String { + if let dropMessage { return dropMessage } + if packetLoggerReady { + return "现在可以返回主窗口安装 Siri Remote Mic 组件。" + } + if isCopying { + return "正在复制到“应用程序”…" + } + switch downloadWatcher.discovery { + case .packetLogger: + return "正确文件已找到:把 Hardware/PacketLogger.app 拖进这个区域。" + case .diskImage: + return "下载已完成:双击 DMG,打开 Hardware,再把 PacketLogger.app 拖进这里。" + case .wrongDiskImage: + return "当前打开的不是正确工具包,请返回 Apple 下载页选择高亮版本。" + case .none: + return "只接受 PacketLogger.app;拖错 DMG 或其它工具不会安装。" + } + } + + private func installDroppedPacketLogger(_ source: URL) { + dropMessage = nil + dropError = false + let resolvedSource = PacketLoggerDownloadWatcher.resolvingFinderAlias(source) + let wasAlias = resolvedSource.standardizedFileURL != source.standardizedFileURL + guard PacketLoggerDownloadWatcher.isValidPacketLogger(at: resolvedSource) else { + dropError = true + dropMessage = source.pathExtension.lowercased() == "dmg" + ? "这里不能拖 DMG。请先双击打开它,再拖 Hardware 里的 PacketLogger.app。" + : "这不是 PacketLogger.app。请拖 Hardware 文件夹里的 PacketLogger.app。" + emphasizeDropTarget() + return + } + + let destination = packetLoggerDestination + if PacketLoggerDownloadWatcher.isValidPacketLogger(at: destination) { + dropMessage = "应用程序中已经有可用的 PacketLogger.app。" + model.refresh() + return + } + guard !FileManager.default.fileExists(atPath: destination.path) else { + dropError = true + dropMessage = "应用程序中已有一个无效的 PacketLogger.app;为安全起见没有覆盖它。" + return + } + + isCopying = true + do { + try FileManager.default.copyItem(at: resolvedSource, to: destination) + isCopying = false + dropMessage = wasAlias + ? "已识别桌面替身并复制真正的 PacketLogger.app,正在检查…" + : "复制完成,正在检查…" + model.refresh() + } catch { + isCopying = false + dropError = true + dropMessage = "复制失败:\(error.localizedDescription)" + } + } + + private func emphasizeDropTarget() { + withAnimation(.spring(response: 0.22, dampingFraction: 0.55)) { + dropScale = 1.07 + } + for index in 0..<6 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08 * Double(index)) { + withAnimation(.easeInOut(duration: 0.08)) { + dropRotation = index.isMultiple(of: 2) ? -2.2 : 2.2 + } + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { + withAnimation(.spring(response: 0.25, dampingFraction: 0.72)) { + dropScale = 1 + dropRotation = 0 + } + } + } + + private var bottomBar: some View { + HStack { + Button("重新打开下载页") { + onOpenDownload() + } + + Spacer() + + if packetLoggerReady { + Button("完成") { + onClose() + } + .buttonStyle(.borderedProminent) + } else { + Button { + model.refresh() + } label: { + if model.isRefreshing { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("正在检查…") + } + } else { + Text("我已放入应用程序,检查") + } + } + .buttonStyle(.borderedProminent) + .disabled(model.isRefreshing) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 13) + .background(.bar) + } +} diff --git a/app/RemoteInputHandler.swift b/app/RemoteInputHandler.swift index d268763..324ec8e 100644 --- a/app/RemoteInputHandler.swift +++ b/app/RemoteInputHandler.swift @@ -205,6 +205,21 @@ class RemoteInputHandler { /// Called on any button activity; use to trigger trackpad re-scan after remote wake. var onButtonActivity: (() -> Void)? + /// One-shot physical-button capture for Settings. While armed, the next real HID button press + /// and its release are consumed before any normal action path. + private var buttonCapture: ((String) -> Void)? + private var capturedButtonAwaitingRelease: String? + static var lastCapturedButton: String? + static var lastCapturedTime: UInt64 = 0 + + func beginButtonCapture(_ completion: @escaping (String) -> Void) { + buttonCapture = completion + capturedButtonAwaitingRelease = nil + } + + func cancelButtonCapture() { + buttonCapture = nil + } // First press after connection: do not perform action (sound already played at connect). private var isFirstPressAfterConnection = false @@ -592,6 +607,30 @@ class RemoteInputHandler { } buttonState[buttonName] = isPressed + // Settings' one-shot recorder accepts HID BUTTONS only — touch/swipe callbacks never enter + // this path. Consume both edges so assigning a button cannot also fire its old action. + if !isPressed, capturedButtonAwaitingRelease == buttonName { + capturedButtonAwaitingRelease = nil + return + } + if isPressed, let capture = buttonCapture { + buttonCapture = nil + capturedButtonAwaitingRelease = buttonName + isFirstPressAfterConnection = false + if buttonName == "power" { RemoteInputHandler.armInputGuard() } + if RemoteInputHandler.onGlassButtons.contains(buttonName) { + RemoteInputHandler.armTouchGuard() + } + RemoteInputHandler.lastProcessedButton = buttonName + RemoteInputHandler.lastProcessedTime = mach_absolute_time() + RemoteInputHandler.lastCapturedButton = buttonName + RemoteInputHandler.lastCapturedTime = RemoteInputHandler.lastProcessedTime + let key = RemoteInputHandler.configKey(for: buttonName) + rmDebug("🎛 captured remote button for Touch Surface switch: \(key)") + capture(key) + return + } + // The remote can sleep between initial enumeration and a later Siri press. Re-send the // gen-3 enable byte at the physical start of every diagnostic trial so a stale activation // cannot explain an otherwise empty voice stream. @@ -675,9 +714,15 @@ class RemoteInputHandler { return } - // Select is the trackpad click — handled separately for click/drag semantics. + // Select keeps its native click/sticky-drag behavior until any Select slot is explicitly + // configured. Once configured it becomes a normal remappable button; an explicit Disabled + // binding consumes it without falling back to the native click. if buttonName == "select" { - handleSelectButton(pressed: intValue == 1) + if selectUsesConfig { + routeButton(buttonName, pressed: pressed) + } else { + handleSelectButton(pressed: intValue == 1) + } return } @@ -942,6 +987,14 @@ class RemoteInputHandler { } } + private var selectUsesConfig: Bool { + guard let controller = controller else { return false } + return ["select", "select.double", "select.triple", + "select.hold", "select.hold2", "select.hold3"].contains { + controller.hasBinding(for: $0) + } + } + /// Arm the release-to-select stages of a hold family (`.hold*` OR `.taphold*`) and raise the /// progress HUD. Returns false — arming nothing — if the button binds no stage in that family, /// so the caller can fall through to the next family or to the plain tap. This is the shared @@ -1469,6 +1522,7 @@ class RemoteInputHandler { case "ringDown": return "ring.down" case "ringLeft": return "ring.left" case "ringRight": return "ring.right" + case "select": return "select" default: return "button.\(buttonName)" } } diff --git a/app/SettingsModel.swift b/app/SettingsModel.swift index 1bbcc55..d0a8bff 100644 --- a/app/SettingsModel.swift +++ b/app/SettingsModel.swift @@ -33,12 +33,42 @@ final class SettingsModel: ObservableObject { /// Set by AppDelegate to push values into the running TouchHandler. var onApply: ((TuneSettings) -> Void)? + /// Starts/stops a one-shot physical Siri Remote button capture in RemoteInputHandler. + var onBeginButtonCapture: ((_ completion: @escaping (String) -> Void) -> Void)? + var onCancelButtonCapture: (() -> Void)? + @Published var isCapturingTouchModeButton = false init(initial: TuneSettings) { self.tune = initial } func resetToDefaults() { + cancelTouchModeButtonCapture() tune = .default } + + func beginTouchModeButtonCapture() { + guard !isCapturingTouchModeButton else { + cancelTouchModeButtonCapture() + return + } + isCapturingTouchModeButton = true + onBeginButtonCapture? { [weak self] key in + guard let self else { return } + self.tune.touchModeSwitchButton = key + self.tune.touchModeSwitchEnabled = true + self.isCapturingTouchModeButton = false + } + } + + func cancelTouchModeButtonCapture() { + onCancelButtonCapture?() + isCapturingTouchModeButton = false + } + + func releaseTouchModeButton() { + cancelTouchModeButtonCapture() + tune.touchModeSwitchEnabled = false + tune.touchModeSwitchButton = nil + } } diff --git a/app/SettingsView.swift b/app/SettingsView.swift index 5d51e50..a2addcc 100644 --- a/app/SettingsView.swift +++ b/app/SettingsView.swift @@ -34,6 +34,7 @@ struct SettingsView: View { Form { deviceSection cursorSection + touchSurfaceSection accelerationSection clickSection circularSection @@ -44,7 +45,10 @@ struct SettingsView: View { .formStyle(.grouped) case .layout: if let config = model.config { - LayoutView(config: config, onSave: { newConfig in + LayoutView( + config: config, + reservedEventKey: model.tune.touchModeSwitchEventKey, + onSave: { newConfig in // Atomic, validated write → hot-reloads → refreshes model.config. A failed // write (invalid config / permissions) leaves the old file intact; log it. do { try ConfigStore.save(newConfig) } @@ -241,8 +245,8 @@ struct SettingsView: View { } } footer: { Text(device.updatedAt == nil - ? "The remote's microphone is not readable on macOS — see docs/mic-reverse-engineering.md" - : "Battery and firmware come from the system Bluetooth stack. The microphone is not readable on macOS.") + ? "Open Setup & Permissions from the menu bar to configure Siri Remote Mic." + : "Battery and firmware come from the system Bluetooth stack. Siri Remote Mic readiness is shown under Setup & Permissions.") .font(.system(size: 11)) } } @@ -262,13 +266,19 @@ struct SettingsView: View { Toggle(isOn: $model.tune.findCursorEnabled) { rowLabel("Find cursor on shake", "cursorarrow.rays") } + if model.tune.findCursorEnabled { + slider(icon: "waveform.path", title: "Shake sensitivity", + value: $model.tune.findCursorSensitivity, range: 0...1, + minIcon: "minus", maxIcon: "plus", + display: { String(format: "%.0f%%", $0 * 100) }) + } Toggle(isOn: $model.tune.focusFollowsCursor) { rowLabel("Focus app under cursor", "macwindow.on.rectangle") } } header: { Text("Cursor") } footer: { - Text("Higher steadiness ignores finger jitter, so it's easier to hold still and click. Find cursor on shake flashes a ring around the pointer when you rapidly shake it back and forth.\n\nFocus app under cursor makes shortcuts land where you're pointing: rest the cursor on another display and the app there becomes frontmost. Only apps already covering that whole display, fullscreen or maximised — raising one of those changes nothing you can see, while doing this to overlapping windows would reshuffle them as the pointer crossed.") + Text("Higher steadiness ignores finger jitter, so it's easier to hold still and click. Find cursor on shake flashes a ring around the pointer when you shake it back and forth; higher sensitivity accepts slower movement and gives the shake more time to complete.\n\nFocus app under cursor makes shortcuts land where you're pointing: rest the cursor on another display and the app there becomes frontmost. Only apps already covering that whole display, fullscreen or maximised — raising one of those changes nothing you can see, while doing this to overlapping windows would reshuffle them as the pointer crossed.") } } @@ -297,6 +307,60 @@ struct SettingsView: View { } } + private var touchSurfaceSection: some View { + Section { + Picker(selection: $model.tune.touchMovesScroll) { + Text("Move pointer").tag(false) + Text("Scroll").tag(true) + } label: { + rowLabel("One-finger movement", "hand.draw") + } + slider(icon: "speedometer", title: "Scroll speed", + value: $model.tune.touchScrollSpeed, range: 20...500, + minIcon: "tortoise.fill", maxIcon: "hare.fill", + display: { String(format: "%.0f", $0) }) + Toggle(isOn: $model.tune.touchScrollAcceleration) { + rowLabel("Scroll acceleration", "gauge.with.dots.needle.50percent") + } + Toggle(isOn: $model.tune.touchModeSwitchEnabled) { + rowLabel("Switch modes with remote", "arrow.triangle.2.circlepath") + } + .disabled(model.tune.touchModeSwitchButton == nil) + + HStack(spacing: 8) { + rowLabel("Switch button", "av.remote") + Spacer() + if model.isCapturingTouchModeButton { + ProgressView().controlSize(.small) + Text("Press a remote button…") + .font(.system(size: 11)).foregroundStyle(.secondary) + Button("Cancel") { model.cancelTouchModeButtonCapture() } + } else if let key = model.tune.touchModeSwitchButton { + Text(LayoutView.inputName(key)) + .font(.system(size: 11, weight: .medium)) + Button("Change…") { model.beginTouchModeButtonCapture() } + Button("Release") { model.releaseTouchModeButton() } + } else { + Button("Set button…") { model.beginTouchModeButtonCapture() } + } + } + + Picker("Activate on", selection: $model.tune.touchModeSwitchTrigger) { + Text("Tap").tag("tap") + Text("Double-tap").tag("double") + Text("Triple-tap").tag("triple") + Text("Hold").tag("hold") + Text("Hold ··").tag("hold2") + Text("Hold ···").tag("hold3") + } + .disabled(model.tune.touchModeSwitchButton == nil) + } header: { + Text("Touch Surface") + } footer: { + Text("Choose whether one finger moves the pointer or scrolls. Scroll speed also controls two-finger scrolling. Acceleration keeps slow movement precise and makes fast flicks travel farther.\n\nThe remote switch temporarily owns only the selected button action; its saved Layout mapping is preserved underneath and returns immediately when the switch is disabled or released.") + } + } + private var clickSection: some View { Section { slider(icon: "hand.tap.fill", title: "Press sensitivity", @@ -346,11 +410,11 @@ struct SettingsView: View { minIcon: "smallcircle.filled.circle.fill", maxIcon: "circle", display: { String(format: "%.0f%%", $0 * 100) }) slider(icon: "timer", title: "Start resistance", - value: $model.tune.circularStartThreshold, range: 0.1...1.5, + value: $model.tune.circularStartThreshold, range: 0...(Double.pi / 4), minIcon: "hare.fill", maxIcon: "tortoise.fill", display: { String(format: "%.0f°", $0 * 180 / .pi) }) slider(icon: "speedometer", title: "Scroll speed", - value: $model.tune.circularPixelsPerRadian, range: 40...400, + value: $model.tune.circularPixelsPerRadian, range: 2...200, minIcon: "tortoise.fill", maxIcon: "hare.fill", display: { String(format: "%.0f", $0) }) slider(icon: "wind", title: "Smoothness", @@ -371,11 +435,11 @@ struct SettingsView: View { .padding(.vertical, 2) slider(icon: "tortoise.fill", title: "Slow gain", - value: $model.tune.circularAccelMin, range: 0.1...1.5, + value: $model.tune.circularAccelMin, range: 0.02...1.5, minIcon: "minus", maxIcon: "plus", display: { String(format: "%.2f×", $0) }) slider(icon: "hare.fill", title: "Fast gain", - value: $model.tune.circularAccelMax, range: 1.0...5.0, + value: $model.tune.circularAccelMax, range: 0.1...5.0, minIcon: "minus", maxIcon: "plus", display: { String(format: "%.2f×", $0) }) slider(icon: "point.topleft.down.curvedto.point.bottomright.up", title: "Curve shape", diff --git a/app/SetupStatus.swift b/app/SetupStatus.swift new file mode 100644 index 0000000..b63955c --- /dev/null +++ b/app/SetupStatus.swift @@ -0,0 +1,317 @@ +// +// SetupStatus.swift +// HyperVibe +// +// One source of truth for the permissions and system components HyperVibe needs. +// The scan is intentionally read-only; every mutation remains behind an explicit user action. +// + +import AppKit +import ApplicationServices +import AVFoundation +import Combine +import CoreBluetooth +import CoreGraphics +import Foundation + +enum SetupRequirement: String, CaseIterable, Identifiable { + case accessibility + case inputMonitoring + case microphone + case bluetooth + case packetLogger + case microphoneComponents + + var id: String { rawValue } +} + +struct SetupStatusItem: Identifiable, Equatable { + let requirement: SetupRequirement + let title: String + let detail: String + let symbol: String + let isComplete: Bool + let actionTitle: String? + + var id: String { requirement.id } +} + +@MainActor +final class SetupStatusModel: ObservableObject { + @Published private(set) var items: [SetupStatusItem] = [] + @Published private(set) var isRefreshing = false + @Published private(set) var isInstalling = false + @Published var errorMessage: String? + + /// Called once, after the first launch scan. AppDelegate uses it to present the setup window + /// only when something is missing; later refreshes never steal focus. + var onInitialScan: ((Bool) -> Void)? + /// Presents the always-on-top download walkthrough before the browser takes focus. + var onShowPacketLoggerGuide: (() -> Void)? + + private var deliveredInitialScan = false + + var missingItems: [SetupStatusItem] { items.filter { !$0.isComplete } } + var completedItems: [SetupStatusItem] { items.filter(\.isComplete) } + var hasMissingRequirements: Bool { !missingItems.isEmpty } + + var shortestRouteTitle: String { + if isInstalling { return "正在安装…" } + if isMissing(.packetLogger) { return "下载 PacketLogger" } + if isMissing(.microphoneComponents) { return "安装麦克风组件" } + if let firstPermission = missingItems.first { return firstPermission.actionTitle ?? "继续设置" } + return "已全部就绪" + } + + func refresh() { + guard !isRefreshing else { return } + isRefreshing = true + + // TCC and CoreBluetooth authorization reads are cheap and should happen on the main actor. + let accessibility = AXIsProcessTrusted() + let inputMonitoring: Bool + if #available(macOS 10.15, *) { + inputMonitoring = CGPreflightListenEventAccess() + } else { + inputMonitoring = true + } + let microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + let bluetooth: Bool + if #available(macOS 11.0, *) { + bluetooth = CBManager.authorization == .allowedAlways + } else { + bluetooth = true + } + + DispatchQueue.global(qos: .userInitiated).async { + let fm = FileManager.default + let packetLogger = fm.isExecutableFile( + atPath: "/Applications/PacketLogger.app/Contents/Resources/packetlogger" + ) + + let componentPaths = [ + "/Library/Audio/Plug-Ins/HAL/SiriRemoteMic.driver/Contents/MacOS/SiriRemoteMic", + "/Library/Application Support/SiriRemoteMic/srm_router", + "/Library/Application Support/SiriRemoteMic/srm_captured", + "/Library/LaunchDaemons/au.holodata.SiriRemoteMic.captured.plist", + ] + let componentsPresent = componentPaths.allSatisfy { fm.fileExists(atPath: $0) } + let daemonLoaded = componentsPresent && Self.isCaptureDaemonLoaded() + + let result = Self.makeItems( + accessibility: accessibility, + inputMonitoring: inputMonitoring, + microphone: microphone, + bluetooth: bluetooth, + packetLogger: packetLogger, + microphoneComponents: componentsPresent && daemonLoaded + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.items = result + self.isRefreshing = false + if !self.deliveredInitialScan { + self.deliveredInitialScan = true + self.onInitialScan?(!result.filter { !$0.isComplete }.isEmpty) + } + } + } + } + + func performAction(for item: SetupStatusItem) { + switch item.requirement { + case .accessibility: + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true + ] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + openPrivacyPane("Privacy_Accessibility") + + case .inputMonitoring: + if #available(macOS 10.15, *) { + _ = CGRequestListenEventAccess() + } + openPrivacyPane("Privacy_ListenEvent") + + case .microphone: + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .notDetermined: + AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in + DispatchQueue.main.async { self?.refresh() } + } + default: + openPrivacyPane("Privacy_Microphone") + } + + case .bluetooth: + openPrivacyPane("Privacy_Bluetooth") + + case .packetLogger: + openPacketLoggerDownload() + + case .microphoneComponents: + installMicrophoneComponents() + } + } + + /// The single primary button advances the first unavoidable step in the shortest route. + func startShortestRoute() { + if isMissing(.packetLogger) { + openPacketLoggerDownload() + } else if isMissing(.microphoneComponents) { + installMicrophoneComponents() + } else if let first = missingItems.first { + performAction(for: first) + } + } + + private func isMissing(_ requirement: SetupRequirement) -> Bool { + items.first(where: { $0.requirement == requirement })?.isComplete == false + } + + private func openPacketLoggerDownload() { + onShowPacketLoggerGuide?() + guard let url = URL( + string: "https://developer.apple.com/download/all/?q=Additional+Tools+for+Xcode" + ) else { return } + NSWorkspace.shared.open(url) + } + + private func openPrivacyPane(_ anchor: String) { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)" + ) else { return } + NSWorkspace.shared.open(url) + } + + private func installMicrophoneComponents() { + guard !isInstalling else { return } + errorMessage = nil + + guard let resources = Bundle.main.resourceURL else { + errorMessage = "找不到应用资源目录。请从 HyperVibe.app 启动,而不是直接运行开发二进制。" + return + } + let payload = resources.appendingPathComponent("MicrophoneSetup", isDirectory: true) + let script = payload.appendingPathComponent("install_mic_components.sh") + guard FileManager.default.fileExists(atPath: script.path) else { + errorMessage = "这个 App 没有包含麦克风安装资源。请重新构建完整安装版。" + return + } + + isInstalling = true + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = [ + "-e", "on run argv", + "-e", "do shell script \"/bin/bash \" & quoted form of item 1 of argv & \" \" & quoted form of item 2 of argv with administrator privileges", + "-e", "end run", + "--", script.path, payload.path, + ] + let errorPipe = Pipe() + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + let data = errorPipe.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + DispatchQueue.main.async { + guard let self else { return } + self.isInstalling = false + if process.terminationStatus == 0 { + self.refresh() + } else if process.terminationStatus != 1 || message?.contains("-128") != true { + self.errorMessage = message?.isEmpty == false + ? message + : "安装未完成,请重试。" + } + } + } catch { + DispatchQueue.main.async { + self?.isInstalling = false + self?.errorMessage = "无法启动安装程序:\(error.localizedDescription)" + } + } + } + } + + nonisolated private static func isCaptureDaemonLoaded() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = ["print", "system/au.holodata.SiriRemoteMic.captured"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + nonisolated private static func makeItems( + accessibility: Bool, + inputMonitoring: Bool, + microphone: Bool, + bluetooth: Bool, + packetLogger: Bool, + microphoneComponents: Bool + ) -> [SetupStatusItem] { + [ + SetupStatusItem( + requirement: .accessibility, + title: "辅助功能", + detail: "用于移动光标和执行你配置的按键操作。", + symbol: "accessibility", + isComplete: accessibility, + actionTitle: accessibility ? nil : "打开设置" + ), + SetupStatusItem( + requirement: .inputMonitoring, + title: "输入监控", + detail: "用于接收遥控器按钮和媒体键事件。", + symbol: "keyboard", + isComplete: inputMonitoring, + actionTitle: inputMonitoring ? nil : "打开设置" + ), + SetupStatusItem( + requirement: .microphone, + title: "麦克风", + detail: "用于在遥控器未说话时接入 Mac 内置麦克风。", + symbol: "mic.fill", + isComplete: microphone, + actionTitle: microphone ? nil : "请求权限" + ), + SetupStatusItem( + requirement: .bluetooth, + title: "蓝牙", + detail: "用于发现并连接已配对的 Siri Remote。", + symbol: "antenna.radiowaves.left.and.right", + isComplete: bluetooth, + actionTitle: bluetooth ? nil : "打开设置" + ), + SetupStatusItem( + requirement: .packetLogger, + title: "Apple PacketLogger", + detail: "Apple 的免费工具,用于读取遥控器的私有蓝牙语音数据。", + symbol: "waveform.badge.magnifyingglass", + isComplete: packetLogger, + actionTitle: packetLogger ? nil : "获取工具" + ), + SetupStatusItem( + requirement: .microphoneComponents, + title: "Siri Remote Mic 组件", + detail: "虚拟麦克风、语音路由器和按需后台服务。", + symbol: "waveform.circle.fill", + isComplete: microphoneComponents, + actionTitle: microphoneComponents ? nil : "安装" + ), + ] + } +} diff --git a/app/SetupWindow.swift b/app/SetupWindow.swift new file mode 100644 index 0000000..5553406 --- /dev/null +++ b/app/SetupWindow.swift @@ -0,0 +1,249 @@ +// +// SetupWindow.swift +// HyperVibe +// + +import AppKit +import SwiftUI + +@MainActor +final class SetupWindowController { + private var window: NSWindow? + private let model: SetupStatusModel + + init(model: SetupStatusModel) { + self.model = model + } + + func show() { + if window == nil { + let hosting = NSHostingController(rootView: SetupView(model: model)) + let win = NSWindow(contentViewController: hosting) + win.title = "HyperVibe 设置与权限" + win.styleMask = [.titled, .closable, .resizable, .fullSizeContentView] + win.titlebarAppearsTransparent = true + win.titleVisibility = .hidden + win.isMovableByWindowBackground = true + win.isReleasedWhenClosed = false + win.contentMinSize = NSSize(width: 520, height: 520) + win.setContentSize(NSSize(width: 560, height: 680)) + win.center() + window = win + } + + model.refresh() + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } +} + +private struct SetupView: View { + @ObservedObject var model: SetupStatusModel + + var body: some View { + VStack(spacing: 0) { + header + Divider() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + if model.isRefreshing && model.items.isEmpty { + HStack { + Spacer() + ProgressView("正在检查这台 Mac…") + Spacer() + } + .padding(.vertical, 80) + } else { + requirementGroup( + title: "需要处理", + items: model.missingItems, + emptyMessage: "没有缺失项目" + ) + requirementGroup( + title: "已完成", + items: model.completedItems, + emptyMessage: nil + ) + } + } + .padding(20) + } + + Divider() + bottomBar + } + .frame(minWidth: 520, idealWidth: 560, minHeight: 520, idealHeight: 680) + .alert("无法继续", isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { + Button("好", role: .cancel) { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "") + } + } + + private var header: some View { + HStack(spacing: 14) { + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill( + LinearGradient( + colors: [Color.accentColor, Color.accentColor.opacity(0.68)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 50, height: 50) + .overlay( + Image(systemName: "mic.and.signal.meter.fill") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.white) + ) + + VStack(alignment: .leading, spacing: 3) { + Text("设置与权限") + .font(.system(size: 20, weight: .semibold)) + Text("HyperVibe 会在每次打开时检查,不会替你静默修改系统。") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + Spacer() + + Label( + model.hasMissingRequirements ? "\(model.missingItems.count) 项待处理" : "全部就绪", + systemImage: model.hasMissingRequirements + ? "exclamationmark.circle.fill" + : "checkmark.circle.fill" + ) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(model.hasMissingRequirements ? Color.orange : Color.green) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(.quaternary)) + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + .background(.bar) + } + + @ViewBuilder + private func requirementGroup( + title: String, + items: [SetupStatusItem], + emptyMessage: String? + ) -> some View { + VStack(alignment: .leading, spacing: 9) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + if items.isEmpty, let emptyMessage { + Text(emptyMessage) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.quaternary.opacity(0.45)) + ) + } else { + ForEach(items) { item in + requirementRow(item) + } + } + } + } + + private func requirementRow(_ item: SetupStatusItem) -> some View { + HStack(spacing: 12) { + Image(systemName: item.symbol) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(item.isComplete ? Color.green : Color.orange) + .frame(width: 34, height: 34) + .background(Circle().fill(.quaternary)) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 7) { + Text(item.title) + .font(.system(size: 13, weight: .semibold)) + Text(item.isComplete ? "已完成" : "未完成") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(item.isComplete ? Color.green : Color.orange) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(.quaternary)) + } + Text(item.detail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 10) + + if let actionTitle = item.actionTitle { + Button(actionTitle) { + model.performAction(for: item) + } + .controlSize(.small) + .disabled(model.isInstalling) + } else { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.secondary.opacity(item.isComplete ? 0.06 : 0.11)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke( + item.isComplete ? Color.clear : Color.orange.opacity(0.28), + lineWidth: 1 + ) + ) + } + + private var bottomBar: some View { + HStack { + Button { + model.refresh() + } label: { + Label("重新扫描", systemImage: "arrow.clockwise") + } + .disabled(model.isRefreshing || model.isInstalling) + + Spacer() + + if model.hasMissingRequirements { + Button { + model.startShortestRoute() + } label: { + if model.isInstalling { + HStack(spacing: 7) { + ProgressView().controlSize(.small) + Text(model.shortestRouteTitle) + } + } else { + Text(model.shortestRouteTitle) + } + } + .buttonStyle(.borderedProminent) + .disabled(model.isRefreshing || model.isInstalling) + } else { + Label("现在可以使用 Siri Remote Mic", systemImage: "checkmark.circle.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.green) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(.bar) + } +} diff --git a/app/ShortcutRecorder.swift b/app/ShortcutRecorder.swift new file mode 100644 index 0000000..87b15f1 --- /dev/null +++ b/app/ShortcutRecorder.swift @@ -0,0 +1,498 @@ +// +// ShortcutRecorder.swift +// HyperVibe +// +// A narrow AppKit bridge for recording physical keyboard shortcuts. SwiftUI TextField cannot +// reliably capture Esc, function keys, modifier-only chords, or menu equivalents such as Cmd-Q: +// the responder chain consumes them first. This view temporarily installs a session event tap +// that captures and suppresses one chord before macOS handles shortcuts such as Option-Tab, then +// returns the stable KeyMap string to SwiftUI. The tap is strictly time-bounded and always removed. +// + +import SwiftUI +import AppKit +import Carbon.HIToolbox + +struct ShortcutRecorder: NSViewRepresentable { + @Binding var value: String + let onRecord: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value, onRecord: onRecord) + } + + func makeNSView(context: Context) -> ShortcutRecorderView { + let view = ShortcutRecorderView() + view.onRecord = { [weak coordinator = context.coordinator] shortcut in + coordinator?.record(shortcut) + } + view.shortcut = value + return view + } + + func updateNSView(_ view: ShortcutRecorderView, context: Context) { + context.coordinator.value = $value + context.coordinator.onRecord = onRecord + if !view.isRecording, view.shortcut != value { + view.shortcut = value + } + } + + final class Coordinator { + var value: Binding + var onRecord: (String) -> Void + + init(value: Binding, onRecord: @escaping (String) -> Void) { + self.value = value + self.onRecord = onRecord + } + + func record(_ shortcut: String) { + value.wrappedValue = shortcut + onRecord(shortcut) + } + } +} + +final class ShortcutRecorderView: NSView { + var onRecord: ((String) -> Void)? + var shortcut = "" { didSet { refresh() } } + private(set) var isRecording = false + + private let label = NSTextField(labelWithString: "") + private let countdownLabel = NSTextField(labelWithString: "") + private let countdownProgress = NSProgressIndicator() + private var localMonitor: Any? + private var eventTap: CFMachPort? + private var eventTapSource: CFRunLoopSource? + private var recordingTimeout: DispatchWorkItem? + private var countdownTimer: Timer? + private var recordingDeadline: TimeInterval = 0 + private var fullCaptureAvailable = false + private var heldModifiers = Set() + private var chordModifiers = Set() + private static let captureDuration: TimeInterval = 6 + + private static let eventTapCallback: CGEventTapCallBack = { + _, type, event, userInfo in + guard let userInfo else { return Unmanaged.passUnretained(event) } + let view = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + return view.handleEventTap(type: type, event: event) + ? nil + : Unmanaged.passUnretained(event) + } + + override var acceptsFirstResponder: Bool { true } + override var intrinsicContentSize: NSSize { NSSize(width: 190, height: 24) } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 5 + layer?.borderWidth = 1 + + label.alignment = .center + label.font = .monospacedSystemFont(ofSize: 11.5, weight: .medium) + label.lineBreakMode = .byTruncatingMiddle + addSubview(label) + + countdownLabel.alignment = .right + countdownLabel.font = .monospacedDigitSystemFont(ofSize: 10.5, weight: .semibold) + countdownLabel.textColor = .secondaryLabelColor + countdownLabel.isHidden = true + addSubview(countdownLabel) + + countdownProgress.style = .bar + countdownProgress.isIndeterminate = false + countdownProgress.minValue = 0 + countdownProgress.maxValue = Self.captureDuration + countdownProgress.controlSize = .mini + countdownProgress.isHidden = true + addSubview(countdownProgress) + + setAccessibilityRole(.button) + setAccessibilityLabel("Keyboard shortcut recorder") + refresh() + } + + required init?(coder: NSCoder) { nil } + + deinit { + recordingTimeout?.cancel() + countdownTimer?.invalidate() + removeEventTap() + if let localMonitor { NSEvent.removeMonitor(localMonitor) } + } + + override func layout() { + super.layout() + if isRecording && fullCaptureAvailable { + let countdownWidth: CGFloat = 40 + label.alignment = .left + label.frame = NSRect( + x: 7, y: 4, + width: max(0, bounds.width - countdownWidth - 16), + height: max(0, bounds.height - 6) + ) + countdownLabel.frame = NSRect( + x: max(7, bounds.width - countdownWidth - 6), y: 4, + width: countdownWidth, height: max(0, bounds.height - 6) + ) + countdownProgress.frame = NSRect( + x: 5, y: 1, + width: max(0, bounds.width - 10), height: 2 + ) + } else { + label.alignment = .center + label.frame = bounds.insetBy(dx: 7, dy: 3) + } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + refresh() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { stopRecording() } + } + + override func mouseDown(with event: NSEvent) { + startRecording() + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } + + override func resignFirstResponder() -> Bool { + stopRecording() + return super.resignFirstResponder() + } + + private func startRecording() { + guard !isRecording else { return } + isRecording = true + heldModifiers.removeAll() + chordModifiers.removeAll() + window?.makeFirstResponder(self) + installCapture() + scheduleTimeout() + refresh() + } + + private func stopRecording() { + guard isRecording || localMonitor != nil || eventTap != nil else { return } + isRecording = false + fullCaptureAvailable = false + heldModifiers.removeAll() + chordModifiers.removeAll() + recordingTimeout?.cancel() + recordingTimeout = nil + countdownTimer?.invalidate() + countdownTimer = nil + removeEventTap() + if let localMonitor { + NSEvent.removeMonitor(localMonitor) + self.localMonitor = nil + } + refresh() + } + + /// Install the session tap first: unlike an AppKit local monitor it sits before macOS shortcut + /// handling, so Tab, Cmd-Tab, Option-Tab, Escape, menu equivalents, and function keys arrive. + /// The local monitor remains only as a mouse-cancel path and a permission-denied fallback. + private func installCapture() { + removeEventTap() + let types: [CGEventType] = [ + .keyDown, .keyUp, .flagsChanged, .leftMouseDown, .rightMouseDown + ] + let mask = types.reduce(CGEventMask(0)) { + $0 | (CGEventMask(1) << CGEventMask($1.rawValue)) + } + eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: Self.eventTapCallback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + if let eventTap { + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + eventTapSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + fullCaptureAvailable = true + } else { + fullCaptureAvailable = false + NSSound.beep() + } + + if let localMonitor { NSEvent.removeMonitor(localMonitor) } + localMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .flagsChanged, .leftMouseDown] + ) { [weak self] event in + guard let self, self.isRecording else { return event } + + switch event.type { + case .keyDown: + // The event tap normally consumes this before AppKit. This fallback is used only + // when macOS declined tap creation (usually missing Accessibility permission). + guard self.eventTap == nil else { return nil } + self.recordKeyDown( + keyCode: event.keyCode, + isRepeat: event.isARepeat, + modifiers: self.modifiers(from: event.modifierFlags) + ) + return nil + case .flagsChanged: + guard self.eventTap == nil else { return nil } + self.recordFlagsChanged(keyCode: event.keyCode) + return nil + case .leftMouseDown: + guard event.window === self.window else { return event } + // Let the click continue. A click elsewhere cancels; clicking this control simply + // restarts recording through mouseDown after this monitor returns. + self.stopRecording() + return event + default: + return event + } + } + } + + private func removeEventTap() { + if let source = eventTapSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + eventTapSource = nil + } + if let eventTap { + CFMachPortInvalidate(eventTap) + self.eventTap = nil + } + } + + private func scheduleTimeout() { + recordingTimeout?.cancel() + countdownTimer?.invalidate() + recordingDeadline = ProcessInfo.processInfo.systemUptime + Self.captureDuration + updateCountdown() + let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in + self?.updateCountdown() + } + countdownTimer = timer + RunLoop.main.add(timer, forMode: .common) + + let work = DispatchWorkItem { [weak self] in + guard let self, self.isRecording else { return } + self.stopRecording() + self.window?.makeFirstResponder(nil) + } + recordingTimeout = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.captureDuration, execute: work) + } + + private func updateCountdown() { + let remaining = max(0, recordingDeadline - ProcessInfo.processInfo.systemUptime) + countdownLabel.stringValue = String(format: "%.1fs", remaining) + countdownProgress.doubleValue = remaining + } + + /// Return true to suppress this event from the rest of the login session. + private func handleEventTap(type: CGEventType, event: CGEvent) -> Bool { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + // A slow callback or secure-input transition may disable a tap. Re-enable while the + // bounded recording window is alive; stop immediately if its port has disappeared. + if isRecording, let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } else { + stopRecording() + } + return false + } + guard isRecording else { return false } + + switch type { + case .keyDown: + recordKeyDown( + keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)), + isRepeat: event.getIntegerValueField(.keyboardEventAutorepeat) != 0, + modifiers: modifiers(from: event.flags) + ) + return true + case .keyUp: + // Do not leak the release half of a chord whose press half was suppressed. + return true + case .flagsChanged: + recordFlagsChanged( + keyCode: UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + ) + return true + case .leftMouseDown, .rightMouseDown: + // A click anywhere after recording begins is an explicit cancel, but the click itself + // continues to its destination. + stopRecording() + return false + default: + return false + } + } + + private func recordKeyDown(keyCode: UInt16, isRepeat: Bool, modifiers: Set) { + guard !isRepeat, let key = KeyMap.token(for: keyCode) else { + NSSound.beep() + return + } + var combined = heldModifiers + // If recording began while modifiers were already held, their flagsChanged press happened + // before the monitor existed. Preserve the chord with generic-side fallback tokens. + for token in modifiers { + if token == "fn" { + combined.insert(token) + } else if !containsSide(of: token, in: combined) { + combined.insert(token) + } + } + finish(tokens: ordered(combined) + [key]) + } + + private func recordFlagsChanged(keyCode: UInt16) { + // Caps Lock is a toggle key, not a held chord modifier. + if Int(keyCode) == kVK_CapsLock { + finish(tokens: ["capslock"]) + return + } + guard let token = KeyMap.modifierToken(for: keyCode) else { + NSSound.beep() + return + } + if heldModifiers.contains(token) { + heldModifiers.remove(token) + if heldModifiers.isEmpty, !chordModifiers.isEmpty { + finish(tokens: ordered(chordModifiers)) + } else { + refresh() + } + } else { + heldModifiers.insert(token) + chordModifiers.insert(token) + refresh() + } + } + + private func modifiers(from flags: NSEvent.ModifierFlags) -> Set { + var result = Set() + if flags.contains(.control) { result.insert("ctrl") } + if flags.contains(.option) { result.insert("opt") } + if flags.contains(.shift) { result.insert("shift") } + if flags.contains(.command) { result.insert("cmd") } + if flags.contains(.function) { result.insert("fn") } + return result + } + + private func modifiers(from flags: CGEventFlags) -> Set { + var result = Set() + if flags.contains(.maskControl) { result.insert("ctrl") } + if flags.contains(.maskAlternate) { result.insert("opt") } + if flags.contains(.maskShift) { result.insert("shift") } + if flags.contains(.maskCommand) { result.insert("cmd") } + if flags.contains(.maskSecondaryFn) { result.insert("fn") } + return result + } + + private func finish(tokens: [String]) { + let result = tokens.joined(separator: "+") + shortcut = result + stopRecording() + onRecord?(result) + window?.makeFirstResponder(nil) + } + + private func containsSide(of family: String, in tokens: Set) -> Bool { + tokens.contains(family) || tokens.contains("l\(family)") || tokens.contains("r\(family)") + } + + private func ordered(_ tokens: Set) -> [String] { + let rank: [String: Int] = [ + "lctrl": 0, "rctrl": 1, "ctrl": 2, + "lopt": 3, "ropt": 4, "opt": 5, + "lshift": 6, "rshift": 7, "shift": 8, + "lcmd": 9, "rcmd": 10, "cmd": 11, + "fn": 12, + ] + return tokens.sorted { + let a = rank[$0] ?? 99, b = rank[$1] ?? 99 + return a == b ? $0 < $1 : a < b + } + } + + private func refresh() { + guard label.superview != nil else { return } + if isRecording { + if fullCaptureAvailable { + label.stringValue = heldModifiers.isEmpty + ? "Press a shortcut…" + : display(ordered(heldModifiers).joined(separator: "+")) + " press a key" + countdownLabel.isHidden = false + countdownProgress.isHidden = false + label.textColor = .controlAccentColor + layer?.borderColor = NSColor.controlAccentColor.cgColor + layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.08).cgColor + setAccessibilityValue("Recording for up to six seconds") + } else { + countdownLabel.isHidden = true + countdownProgress.isHidden = true + label.stringValue = "Full capture unavailable" + label.textColor = .systemRed + layer?.borderColor = NSColor.systemRed.cgColor + layer?.backgroundColor = NSColor.systemRed.withAlphaComponent(0.08).cgColor + setAccessibilityValue("Full capture unavailable; check Accessibility permission") + } + } else { + countdownLabel.isHidden = true + countdownProgress.isHidden = true + label.stringValue = shortcut.isEmpty ? "Click to record" : display(shortcut) + label.textColor = shortcut.isEmpty ? .secondaryLabelColor : .labelColor + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + setAccessibilityValue(shortcut.isEmpty ? "No shortcut" : shortcut) + } + needsLayout = true + needsDisplay = true + } + + private func display(_ value: String) -> String { + value.split(separator: "+").map { token in + switch token.lowercased() { + case "cmd", "lcmd": return "⌘" + case "rcmd": return "⌘R" + case "ctrl", "lctrl": return "⌃" + case "rctrl": return "⌃R" + case "opt", "lopt": return "⌥" + case "ropt": return "⌥R" + case "shift", "lshift": return "⇧" + case "rshift": return "⇧R" + case "fn": return "fn" + case "up": return "↑" + case "down": return "↓" + case "left": return "←" + case "right": return "→" + case "esc": return "Esc" + case "enter": return "Return" + case "delete": return "Delete" + case "forwarddelete": return "Forward Delete" + case "space": return "Space" + case "tab": return "Tab" + case "pageup": return "Page Up" + case "pagedown": return "Page Down" + default: + if token.lowercased().hasPrefix("keycode") { + return "Key \(token.dropFirst("keycode".count))" + } + return token.uppercased() + } + }.joined(separator: " ") + } +} diff --git a/app/SiriRemoteApp.swift b/app/SiriRemoteApp.swift index 3997fe0..0c75dbb 100644 --- a/app/SiriRemoteApp.swift +++ b/app/SiriRemoteApp.swift @@ -39,10 +39,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var controller: Controller? private var appWatcher: AppWatcher? private var configWatcher: ConfigFileWatcher? + private var touchModeSwitchOverrideKey: String? // Settings UI private var settingsModel: SettingsModel? private var settingsWindow: SettingsWindowController? + private var setupModel: SetupStatusModel? + private var setupWindow: SetupWindowController? + private var packetLoggerGuideWindow: PacketLoggerGuideWindowController? /// Debounces persisting Tuning-tab slider changes back into config.jsonc. private var tunePersistWork: DispatchWorkItem? @@ -208,10 +212,31 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Initialize menu bar manager menuBarManager = MenuBarManager(statusItem: statusItem) - - // Check accessibility permissions - checkAccessibilityPermissions() - + + // Read-only launch scan. Missing requirements are presented in one ordered window instead + // of firing several unrelated system prompts during startup. Every permission/install + // mutation remains behind a button the user explicitly presses. + let readiness = SetupStatusModel() + let setupWin = SetupWindowController(model: readiness) + setupModel = readiness + setupWindow = setupWin + let packetLoggerGuide = PacketLoggerGuideWindowController(model: readiness) + packetLoggerGuideWindow = packetLoggerGuide + menuBarManager.onOpenSetup = { [weak setupWin] in setupWin?.show() } + readiness.onShowPacketLoggerGuide = { [weak packetLoggerGuide] in + packetLoggerGuide?.show() + } + readiness.onInitialScan = { [weak setupWin] hasMissing in + if hasMissing { setupWin?.show() } + } + readiness.refresh() + if CommandLine.arguments.contains("--packetlogger-guide") + || (Bundle.main.object( + forInfoDictionaryKey: "HyperVibeShowPacketLoggerGuideOnLaunch" + ) as? Bool == true) { + DispatchQueue.main.async { packetLoggerGuide.show() } + } + // Initialize controllers let cursorController = CursorController() @@ -232,6 +257,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { self?.scheduleTunePersist() // write slider values back into config.jsonc (debounced) } model.config = config // publish the live config to the Settings "Layout" tab + model.onBeginButtonCapture = { [weak self] completion in + self?.remoteInputHandler?.beginButtonCapture(completion) + } + model.onCancelButtonCapture = { [weak self] in + self?.remoteInputHandler?.cancelButtonCapture() + } settingsModel = model let settingsWin = SettingsWindowController(model: model) settingsWindow = settingsWin @@ -240,6 +271,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { if CommandLine.arguments.contains("--settings") { DispatchQueue.main.async { settingsWin.show() } } + if CommandLine.arguments.contains("--setup") { + DispatchQueue.main.async { setupWin.show() } + } // The launcher is summoned by an ordinary `.appWheel` hold binding, so it arrives here as an // action like any other — and inherits the progress card that every hold gets. @@ -281,7 +315,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Start touch handler for trackpad (before remote detection so we can wire the callback) touchHandler = TouchHandler(cursorController: cursorController) - touchHandler?.scrollScale = menuBarManager.scrollSpeed.scale + touchHandler?.onTap = { [weak self] in + guard let self = self else { return } + self.remoteInputHandler?.noteLayerUsedByOtherInput() + // A configured tap.one action wins, including explicit Disabled. With no binding, + // preserve the historical native fallback: a light touch clicks. + if self.controller?.handle(InputEvent(key: "tap.one")) == true { + print("👆 tap.one (config)") + } else { + cursorController.performClick() + } + } touchHandler?.onSwipe = { [weak self] direction in // Swipes are config-driven only. An unbound swipe does nothing — no native fallback, // so HyperVibe's Claude-Code default swipe keys (e.g. right = Shift+Tab) no longer @@ -418,13 +462,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // Request Input Monitoring so media key tap works in both CLI and .app - if #available(macOS 10.15, *) { - if !CGPreflightListenEventAccess() { - CGRequestListenEventAccess() - } - } - // Virtual-mic fallback (Phase 2b): keep the "Siri Remote Mic" device fed with the // Mac's BUILT-IN microphone whenever the Siri button isn't held. Demand-gated on the // plug-in's consumers notification — the mic is only hot while some app actually has @@ -446,6 +483,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func applyTune(_ t: TuneSettings) { touchHandler?.cursorSpeed = CGFloat(t.cursorSpeed) touchHandler?.cursorDeadzone = CGFloat(t.cursorDeadzone) + touchHandler?.touchMovesScroll = t.touchMovesScroll + touchHandler?.scrollScale = CGFloat(t.touchScrollSpeed) + touchHandler?.scrollAccelerationEnabled = t.touchScrollAcceleration + let newOverride = t.touchModeSwitchEventKey + if let old = touchModeSwitchOverrideKey, old != newOverride { + controller?.setRuntimeOverride(for: old, handler: nil) + } + if let eventKey = newOverride { + controller?.setRuntimeOverride( + for: eventKey, + presentation: Config.Presentation( + label: "Toggle Touch movement", + icon: "arrow.triangle.2.circlepath" + ) + ) { [weak self] in + self?.toggleTouchMovement() + } + } + touchModeSwitchOverrideKey = newOverride touchHandler?.accelMin = CGFloat(t.accelMin) touchHandler?.accelMax = CGFloat(t.accelMax) touchHandler?.accelLowSpeed = CGFloat(t.accelLowSpeed) @@ -459,6 +515,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { remoteInputHandler?.holdCancelGrace = t.holdCancelGrace remoteInputHandler?.doubleTapWindow = t.doubleTapWindow remoteInputHandler?.spacesModeWindow = t.spacesModeWindow + touchHandler?.shakeSensitivity = t.findCursorSensitivity findCursorEnabled = t.findCursorEnabled focusFollower?.enabled = t.focusFollowsCursor } @@ -480,6 +537,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { let merged = base.withSettingsUpdated { s in s.cursorSpeed = t.cursorSpeed s.cursorDeadzone = t.cursorDeadzone + s.touchMovesScroll = t.touchMovesScroll + s.touchScrollSpeed = t.touchScrollSpeed + s.touchScrollAcceleration = t.touchScrollAcceleration + s.touchModeSwitchEnabled = t.touchModeSwitchEnabled + s.touchModeSwitchButton = t.touchModeSwitchButton + s.touchModeSwitchTrigger = t.touchModeSwitchTrigger s.accelMin = t.accelMin s.accelMax = t.accelMax s.accelLowSpeed = t.accelLowSpeed @@ -493,6 +556,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { s.doubleTapWindow = t.doubleTapWindow s.spacesModeWindow = t.spacesModeWindow s.findCursorEnabled = t.findCursorEnabled + s.findCursorSensitivity = t.findCursorSensitivity s.focusFollowsCursor = t.focusFollowsCursor s.circularScroll = t.circularConfig } @@ -502,6 +566,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { catch { NSLog("[siriRemote] tune persist failed: \(error)") } } + private func toggleTouchMovement() { + guard var tune = settingsModel?.tune else { return } + tune.touchMovesScroll.toggle() + settingsModel?.tune = tune + rmDebug("🎛 Touch movement switched to \(tune.touchMovesScroll ? "Scroll" : "Move pointer")") + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } @@ -513,6 +584,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { settingsWindow?.show() return true } + + /// Returning from System Settings is the common permission flow. Re-scan immediately so the + /// moved toggle drops into the completed section without making the user press Refresh. + func applicationDidBecomeActive(_ notification: Notification) { + setupModel?.refresh() + } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { cleanup() @@ -604,17 +681,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { let bound = [base, base + ".double", base + ".triple", base + ".hold", base + ".hold2", base + ".hold3"] .contains { controller?.hasBinding(for: $0) ?? false } - return fromRemote && bound + let captured = RemoteInputHandler.lastCapturedButton == buttonName + && Self.machDeltaToSeconds(from: RemoteInputHandler.lastCapturedTime) < 0.3 + return fromRemote && (bound || captured) } - // MARK: - Permissions - - private func checkAccessibilityPermissions() { - // macOS will show its own prompt when needed - // No need for redundant custom alert - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary - _ = AXIsProcessTrustedWithOptions(options) - } } /// Suspends `com.apple.rcd` (Remote Control Daemon) for the user's GUI launchd domain while diff --git a/app/TouchHandler.swift b/app/TouchHandler.swift index 893c5bb..60361f9 100644 --- a/app/TouchHandler.swift +++ b/app/TouchHandler.swift @@ -61,7 +61,12 @@ class TouchHandler { private var fastReconnectTimer: Timer? private var wakeObserver: NSObjectProtocol? - var scrollScale: CGFloat = 150.0 + /// One-finger movement can be switched from pointer motion to linear scrolling. + var touchMovesScroll = false + /// Shared scale for one-finger Move → Scroll and the existing two-finger scroll gesture. + var scrollScale: CGFloat = 300.0 + /// Velocity gain for linear scrolling. Off keeps a predictable 1:1 response. + var scrollAccelerationEnabled = false private var lastTouchPosition: CGPoint? private var lastTouchCount = 0 @@ -110,6 +115,8 @@ class TouchHandler { private var didScroll = false /// Sub-pixel accumulator so smooth continuous rotation emits whole scroll pixels as they add up. private var scrollRemainder: Double = 0 + private var linearScrollRemainderX: CGFloat = 0 + private var linearScrollRemainderY: CGFloat = 0 /// Press-to-click freeze: pressing to click makes contact (zTotal) spike upward. A per-frame /// rise above this threshold = a press starting → freeze the cursor for a short window so the /// press/release doesn't drift the pointer. @@ -140,22 +147,27 @@ class TouchHandler { var onSwipe: ((SwipeDirection) -> Void)? /// Fired on touch-up for a still two-finger tap (a two-finger drag scrolls instead). var onTwoFingerTap: (() -> Void)? + /// Fired on touch-up for a still one-finger tap. The app supplies config/native fallback. + var onTap: (() -> Void)? /// Fired when the cursor is "shaken" (rapid horizontal back-and-forth) — used to trigger the /// find-my-cursor highlight. Dispatched on main. Wiring gates it on the enabled setting. var onShake: (() -> Void)? // MARK: - Shake-to-locate detection // Feeds the per-frame horizontal movement (post-deadzone, PRE-accel) into a sign-reversal - // counter: each time dx flips sign while |dx| is above `shakeSpeedThreshold`, a reversal is - // recorded; `shakeReversals` reversals within `shakeWindow` seconds fire `onShake`. Debounced - // so it can't re-fire faster than `shakeDebounce`. + // counter: each time dx flips sign while |dx| is above the sensitivity-derived threshold, a + // reversal is recorded. Enough reversals inside the sensitivity-derived window fire onShake. /// Reversals required within the window to count as a shake. var shakeReversals: Int = 3 - /// Sliding window (seconds) the reversals must fall within. - var shakeWindow: TimeInterval = 0.45 - /// Minimum per-frame |dx| (normalized units, same as the deadzone) for a frame to count — - /// gates out slow drift so only a brisk shake triggers. - var shakeSpeedThreshold: CGFloat = 0.02 + /// 0...1. Higher values accept a slower shake and allow more time for its reversals. + var shakeSensitivity: Double = 0.5 + private var effectiveShakeWindow: TimeInterval { + 0.25 + (0.40 * min(max(shakeSensitivity, 0), 1)) + } + private var effectiveShakeSpeedThreshold: CGFloat { + let sensitivity = CGFloat(min(max(shakeSensitivity, 0), 1)) + return 0.034 - (0.028 * sensitivity) + } /// Minimum seconds between two shake fires. private let shakeDebounce: TimeInterval = 0.4 private var shakeLastSign = 0 @@ -467,6 +479,8 @@ class TouchHandler { circularActive = false didScroll = false scrollRemainder = 0 + linearScrollRemainderX = 0 + linearScrollRemainderY = 0 rotationTotal = 0 scrollEmitted = 0 lastContact = contactSize @@ -527,6 +541,18 @@ class TouchHandler { return } } + // Optional Move → Scroll mode. A stationary contact can still become `tap.one`; once + // meaningful movement scrolls, `didScroll` prevents that same contact from clicking. + if touchMovesScroll { + if hypot(deltaX, deltaY) > 0.001 { + performScroll(deltaX: deltaX, deltaY: deltaY) + didScroll = true + } + lastContact = contactSize + lastTouchPosition = currentPos + lastTouchCount = activeTouchCount + return + } // Press-to-click freeze: pressing to click spikes contact (zTotal) upward. A sharp // per-frame rise = a press starting → freeze the cursor for a short window covering the // press + click + release. Also freeze while the physical click is held. Re-anchor so @@ -634,8 +660,7 @@ class TouchHandler { if duration < tapMaxDuration && movement < tapMaxDistance { DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.cursorController.performClick() + self?.onTap?() } } } @@ -666,16 +691,17 @@ class TouchHandler { } /// Shake detector: count horizontal sign reversals of brisk motion; fire `onShake` when - /// `shakeReversals` land within `shakeWindow`. `now` is the MT frame timestamp (seconds). + /// `shakeReversals` land within the sensitivity-derived window. `now` is the MT frame + /// timestamp (seconds). private func detectShake(dx: CGFloat, timestamp now: Double) { guard onShake != nil else { return } // Only frames with brisk horizontal motion participate; slow drift neither counts nor // resets the tracked sign. - guard abs(dx) >= shakeSpeedThreshold else { return } + guard abs(dx) >= effectiveShakeSpeedThreshold else { return } let sign = dx > 0 ? 1 : -1 if shakeLastSign != 0 && sign != shakeLastSign { shakeReversalTimes.append(now) - shakeReversalTimes.removeAll { now - $0 > shakeWindow } + shakeReversalTimes.removeAll { now - $0 > effectiveShakeWindow } if shakeReversalTimes.count >= shakeReversals && now - shakeLastFireTime > shakeDebounce { shakeLastFireTime = now shakeReversalTimes.removeAll() @@ -715,8 +741,25 @@ class TouchHandler { } private func performScroll(deltaX: CGFloat, deltaY: CGFloat) { - let scrollX = Int32(-deltaX * scrollScale) - let scrollY = Int32(deltaY * scrollScale) + let velocity = hypot(deltaX, deltaY) + let gain: CGFloat + if scrollAccelerationEnabled { + // Slow motion stays precise; a deliberate flick gains reach. The speed slider remains + // the overall scale, so turning acceleration off is immediately predictable. + let t = smoothstep(velocity, 0.004, 0.04) + gain = 0.5 + 1.7 * t + } else { + gain = 1 + } + linearScrollRemainderX += -deltaX * scrollScale * gain + linearScrollRemainderY += deltaY * scrollScale * gain + let wholeX = linearScrollRemainderX.rounded(.towardZero) + let wholeY = linearScrollRemainderY.rounded(.towardZero) + linearScrollRemainderX -= wholeX + linearScrollRemainderY -= wholeY + guard wholeX != 0 || wholeY != 0 else { return } + let scrollX = Int32(wholeX) + let scrollY = Int32(wholeY) DispatchQueue.main.async { [weak self] in self?.cursorController.scroll(deltaX: scrollX, deltaY: scrollY) diff --git a/app/TuneSettings.swift b/app/TuneSettings.swift index ec56ee4..f567abb 100644 --- a/app/TuneSettings.swift +++ b/app/TuneSettings.swift @@ -11,6 +11,12 @@ import Foundation struct TuneSettings: Codable, Equatable { var cursorSpeed: Double var cursorDeadzone: Double + var touchMovesScroll: Bool + var touchScrollSpeed: Double + var touchScrollAcceleration: Bool + var touchModeSwitchEnabled: Bool + var touchModeSwitchButton: String? + var touchModeSwitchTrigger: String var accelMin: Double var accelMax: Double var accelLowSpeed: Double @@ -24,6 +30,7 @@ struct TuneSettings: Codable, Equatable { var doubleTapWindow: Double var spacesModeWindow: Double var findCursorEnabled: Bool + var findCursorSensitivity: Double var focusFollowsCursor: Bool var circularEnabled: Bool var circularMinRadius: Double @@ -39,10 +46,14 @@ struct TuneSettings: Codable, Equatable { var circularAccelCurve: Double static let `default` = TuneSettings( - cursorSpeed: 0.6, cursorDeadzone: 0.006, accelMin: 0.4, accelMax: 2.6, + cursorSpeed: 0.6, cursorDeadzone: 0.006, + touchMovesScroll: false, touchScrollSpeed: 300, touchScrollAcceleration: false, + touchModeSwitchEnabled: false, touchModeSwitchButton: nil, touchModeSwitchTrigger: "tap", + accelMin: 0.4, accelMax: 2.6, accelLowSpeed: 0.008, accelHighSpeed: 0.06, clickRiseThreshold: 0.1, pressMoveMax: 0.025, holdThreshold: 0.5, holdThreshold2: 1.0, holdThreshold3: 1.6, holdCancelGrace: 1.0, doubleTapWindow: 0.3, spacesModeWindow: 5.0, findCursorEnabled: true, + findCursorSensitivity: 0.5, focusFollowsCursor: false, circularEnabled: true, circularMinRadius: 0.35, circularStartThreshold: 0.35, circularPixelsPerRadian: 75, @@ -55,6 +66,12 @@ struct TuneSettings: Codable, Equatable { init(seed s: Config.Settings) { cursorSpeed = s.cursorSpeed cursorDeadzone = s.cursorDeadzone + touchMovesScroll = s.touchMovesScroll + touchScrollSpeed = s.touchScrollSpeed + touchScrollAcceleration = s.touchScrollAcceleration + touchModeSwitchEnabled = s.touchModeSwitchEnabled + touchModeSwitchButton = s.touchModeSwitchButton + touchModeSwitchTrigger = s.touchModeSwitchTrigger accelMin = s.accelMin accelMax = s.accelMax accelLowSpeed = s.accelLowSpeed @@ -68,10 +85,11 @@ struct TuneSettings: Codable, Equatable { doubleTapWindow = s.doubleTapWindow spacesModeWindow = s.spacesModeWindow findCursorEnabled = s.findCursorEnabled + findCursorSensitivity = s.findCursorSensitivity focusFollowsCursor = s.focusFollowsCursor circularEnabled = s.circularScroll.enabled circularMinRadius = s.circularScroll.minRadius - circularStartThreshold = s.circularScroll.startThreshold + circularStartThreshold = min(max(s.circularScroll.startThreshold, 0), .pi / 4) circularPixelsPerRadian = s.circularScroll.pixelsPerRadian circularScrollEase = s.circularScroll.scrollEase circularInvert = s.circularScroll.invert @@ -82,12 +100,17 @@ struct TuneSettings: Codable, Equatable { circularAccelCurve = s.circularScroll.accelCurve } - init(cursorSpeed: Double, cursorDeadzone: Double, accelMin: Double, accelMax: Double, + init(cursorSpeed: Double, cursorDeadzone: Double, + touchMovesScroll: Bool, touchScrollSpeed: Double, touchScrollAcceleration: Bool, + touchModeSwitchEnabled: Bool, touchModeSwitchButton: String?, + touchModeSwitchTrigger: String, + accelMin: Double, accelMax: Double, accelLowSpeed: Double, accelHighSpeed: Double, clickRiseThreshold: Double, pressMoveMax: Double, holdThreshold: Double, holdThreshold2: Double, holdThreshold3: Double, holdCancelGrace: Double, doubleTapWindow: Double, - spacesModeWindow: Double, findCursorEnabled: Bool, focusFollowsCursor: Bool, + spacesModeWindow: Double, findCursorEnabled: Bool, findCursorSensitivity: Double, + focusFollowsCursor: Bool, circularEnabled: Bool, circularMinRadius: Double, circularStartThreshold: Double, circularPixelsPerRadian: Double, circularScrollEase: Double, circularInvert: Bool, @@ -96,6 +119,12 @@ struct TuneSettings: Codable, Equatable { circularAccelCurve: Double) { self.cursorSpeed = cursorSpeed self.cursorDeadzone = cursorDeadzone + self.touchMovesScroll = touchMovesScroll + self.touchScrollSpeed = touchScrollSpeed + self.touchScrollAcceleration = touchScrollAcceleration + self.touchModeSwitchEnabled = touchModeSwitchEnabled + self.touchModeSwitchButton = touchModeSwitchButton + self.touchModeSwitchTrigger = touchModeSwitchTrigger self.accelMin = accelMin self.accelMax = accelMax self.accelLowSpeed = accelLowSpeed @@ -109,6 +138,7 @@ struct TuneSettings: Codable, Equatable { self.doubleTapWindow = doubleTapWindow self.spacesModeWindow = spacesModeWindow self.findCursorEnabled = findCursorEnabled + self.findCursorSensitivity = findCursorSensitivity self.focusFollowsCursor = focusFollowsCursor self.circularEnabled = circularEnabled self.circularMinRadius = circularMinRadius @@ -138,4 +168,19 @@ struct TuneSettings: Codable, Equatable { accelHighSpeed: circularAccelHighSpeed, accelCurve: circularAccelCurve) } + + /// Exact config event reserved by the Touch Surface switch, or nil while disabled/unassigned. + var touchModeSwitchEventKey: String? { + guard touchModeSwitchEnabled, let button = touchModeSwitchButton else { return nil } + let suffix: String + switch touchModeSwitchTrigger { + case "double": suffix = ".double" + case "triple": suffix = ".triple" + case "hold": suffix = ".hold" + case "hold2": suffix = ".hold2" + case "hold3": suffix = ".hold3" + default: suffix = "" + } + return button + suffix + } } diff --git a/app/build.sh b/app/build.sh index f220618..3c52333 100755 --- a/app/build.sh +++ b/app/build.sh @@ -7,6 +7,12 @@ set -e echo "Building HyperVibe..." +# Keep Swift/Clang's generated module cache out of user-global cache directories. This makes the +# canonical build work from sandboxed development tools and avoids stale cross-project modules. +HYPERVIBE_MODULE_CACHE="${CLANG_MODULE_CACHE_PATH:-/private/tmp/hypervibe-module-cache}" +mkdir -p "$HYPERVIBE_MODULE_CACHE" +export CLANG_MODULE_CACHE_PATH="$HYPERVIBE_MODULE_CACHE" + SWIFT_FILES=( "main.swift" "SiriRemoteApp.swift" @@ -16,6 +22,10 @@ SWIFT_FILES=( "GATTDiagnostics.swift" "NativePushToTalk.swift" "BuiltinMicFeeder.swift" + "SetupStatus.swift" + "SetupWindow.swift" + "PacketLoggerGuideWindow.swift" + "PacketLoggerDropTarget.swift" "CursorController.swift" "FocusFollowsCursor.swift" "MediaController.swift" @@ -39,6 +49,7 @@ SWIFT_FILES=( "SettingsWindow.swift" "RemoteView.swift" "LayoutView.swift" + "ShortcutRecorder.swift" # --- Config engine integration (this fork) --- "KeyMap.swift" "MacActionExecutor.swift" @@ -61,8 +72,9 @@ SWIFT_FILES=( "../SiriRemoteCore/Sources/SiriRemoteCore/Placeholder.swift" ) -# Find SDK path -SDK_PATH=$(xcrun --show-sdk-path --sdk macosx 2>/dev/null || echo "") +# Find SDK path. Respect SDKROOT so builds can select a compatible SDK when +# multiple Command Line Tools SDKs are installed. +SDK_PATH="${SDKROOT:-$(xcrun --show-sdk-path --sdk macosx 2>/dev/null || echo "")}" if [ -z "$SDK_PATH" ]; then echo "Error: macOS SDK not found. Please install Xcode Command Line Tools:" diff --git a/app/create_app_bundle.sh b/app/create_app_bundle.sh index c2f1b93..182b37f 100755 --- a/app/create_app_bundle.sh +++ b/app/create_app_bundle.sh @@ -6,6 +6,8 @@ set -e APP_NAME="HyperVibe" APP_BUNDLE="${APP_NAME}.app" +APP_VERSION="${SRM_APP_VERSION:-1.0}" +APP_BUILD="${SRM_APP_BUILD:-1}" if [ ! -f "$APP_NAME" ]; then echo "Error: $APP_NAME executable not found." @@ -51,6 +53,31 @@ if [ -d "Resources" ]; then echo "Menu bar icons added to app bundle" fi +# Build and embed the repair/install payload used by the in-app "Setup & Permissions" window. +# This keeps the end-user path inside HyperVibe: after PacketLogger is present, one button installs +# the virtual mic, router and daemon with one standard macOS administrator prompt. +# +# Development-only escape hatch: SRM_SKIP_MIC_PAYLOAD=1 ./create_app_bundle.sh +if [ "${SRM_SKIP_MIC_PAYLOAD:-0}" != "1" ]; then + echo "Building Siri Remote Mic setup payload..." + (cd "../mic/router" && ./build.sh) + (cd "../mic/driver" && ./build.sh) + (cd "../mic/captured" && ./build.sh) + + MIC_PAYLOAD="${APP_BUNDLE}/Contents/Resources/MicrophoneSetup" + rm -rf "$MIC_PAYLOAD" + mkdir -p "$MIC_PAYLOAD" + cp -R "../mic/driver/SiriRemoteMic.driver" "$MIC_PAYLOAD/" + cp "../mic/router/srm_router" "$MIC_PAYLOAD/" + cp "../mic/captured/srm_captured" "$MIC_PAYLOAD/" + cp "../mic/captured/au.holodata.SiriRemoteMic.captured.plist" "$MIC_PAYLOAD/" + cp "../dist/install_mic_components.sh" "$MIC_PAYLOAD/" + chmod 755 "$MIC_PAYLOAD/install_mic_components.sh" + echo "Siri Remote Mic setup payload embedded" +else + echo "Skipping Siri Remote Mic setup payload (SRM_SKIP_MIC_PAYLOAD=1)" +fi + # Create proper Info.plist with all required keys echo "Creating Info.plist..." cat > "${APP_BUNDLE}/Contents/Info.plist" < "${APP_BUNDLE}/Contents/Info.plist" <CFBundlePackageType APPL CFBundleVersion - 1.0 + $APP_BUILD CFBundleShortVersionString - 1.0 + $APP_VERSION CFBundleIconFile HyperVibe NSHumanReadableCopyright diff --git a/dist/install_mic_components.sh b/dist/install_mic_components.sh new file mode 100644 index 0000000..adb189b --- /dev/null +++ b/dist/install_mic_components.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# install_mic_components.sh — privileged component-only install launched by HyperVibe itself. +# $1 is HyperVibe.app/Contents/Resources/MicrophoneSetup. +set -euo pipefail + +PAYLOAD="${1:?microphone setup payload required}" +[ -d "$PAYLOAD" ] || { echo "安装资源不存在:$PAYLOAD" >&2; exit 1; } +[ -x "/Applications/PacketLogger.app/Contents/Resources/packetlogger" ] || { + echo "请先将 PacketLogger.app 放入“应用程序”文件夹。" >&2 + exit 1 +} + +SUPPORT="/Library/Application Support/SiriRemoteMic" +HAL="/Library/Audio/Plug-Ins/HAL" +DRIVER="SiriRemoteMic.driver" +PLIST_NAME="au.holodata.SiriRemoteMic.captured.plist" +PLIST_DST="/Library/LaunchDaemons/$PLIST_NAME" + +for required in "$DRIVER" srm_router srm_captured "$PLIST_NAME"; do + [ -e "$PAYLOAD/$required" ] || { + echo "安装包不完整,缺少:$required" >&2 + exit 1 + } +done + +/usr/bin/xattr -dr com.apple.quarantine "$PAYLOAD" 2>/dev/null || true + +/bin/mkdir -p "$HAL" "$SUPPORT" +/bin/rm -rf "$HAL/$DRIVER" +/bin/cp -R "$PAYLOAD/$DRIVER" "$HAL/" +/usr/sbin/chown -R root:wheel "$HAL/$DRIVER" + +/bin/cp "$PAYLOAD/srm_router" "$SUPPORT/srm_router" +/bin/cp "$PAYLOAD/srm_captured" "$SUPPORT/srm_captured" +/usr/sbin/chown root:wheel "$SUPPORT/srm_router" "$SUPPORT/srm_captured" +/bin/chmod 755 "$SUPPORT/srm_router" "$SUPPORT/srm_captured" + +/bin/cp "$PAYLOAD/$PLIST_NAME" "$PLIST_DST" +/usr/sbin/chown root:wheel "$PLIST_DST" +/bin/chmod 644 "$PLIST_DST" + +/bin/launchctl unload -w "$PLIST_DST" 2>/dev/null || true +/bin/launchctl load -w "$PLIST_DST" + +# Loading a HAL plug-in requires one brief, machine-wide CoreAudio restart. +/usr/bin/killall coreaudiod 2>/dev/null || true + +echo "Siri Remote Mic 组件安装完成。" diff --git a/script/build_and_run.sh b/script/build_and_run.sh new file mode 100755 index 0000000..362c151 --- /dev/null +++ b/script/build_and_run.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +APP_NAME="HyperVibe" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_DIR="$ROOT_DIR/app" +APP_BUNDLE="$APP_DIR/$APP_NAME.app" + +pkill -x "$APP_NAME" >/dev/null 2>&1 || true + +if [[ -z "${SDKROOT:-}" ]] && [[ -d /Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk ]]; then + export SDKROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk +fi + +( + cd "$APP_DIR" + ./build.sh + ./create_app_bundle.sh +) + +open_app() { + /usr/bin/open -n "$APP_BUNDLE" +} + +case "$MODE" in + run) + open_app + ;; + --debug|debug) + lldb -- "$APP_BUNDLE/Contents/MacOS/$APP_NAME" + ;; + --logs|logs) + open_app + /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" + ;; + --telemetry|telemetry) + open_app + /usr/bin/log stream --info --style compact --predicate 'subsystem == "com.hypervibe.app"' + ;; + --verify|verify) + open_app + sleep 2 + pgrep -x "$APP_NAME" >/dev/null + ;; + *) + echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 + exit 2 + ;; +esac