@lattices/cli 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,17 +26,17 @@
26
26
  </dict>
27
27
  </array>
28
28
  <key>CFBundleVersion</key>
29
- <string>0.4.11</string>
29
+ <string>0.4.13</string>
30
30
  <key>CFBundleShortVersionString</key>
31
- <string>0.4.11</string>
31
+ <string>0.4.13</string>
32
32
  <key>LatticesBuildChannel</key>
33
33
  <string>dev</string>
34
34
  <key>LatticesBuildTrack</key>
35
35
  <string>latest</string>
36
36
  <key>LatticesBuildRevision</key>
37
- <string>9ce9e1a</string>
37
+ <string>42928c7</string>
38
38
  <key>LatticesBuildTimestamp</key>
39
- <string>2026-05-04T15:09:03Z</string>
39
+ <string>2026-05-04T17:39:23Z</string>
40
40
  <key>LSMinimumSystemVersion</key>
41
41
  <string>13.0</string>
42
42
  <key>LSUIElement</key>
@@ -21,11 +21,12 @@ final class KeyboardRemapController: ObservableObject {
21
21
  private var capsLayerLastEventAt: CFAbsoluteTime?
22
22
  private var bypassUntil: CFAbsoluteTime = 0
23
23
  private var lastCapsLayerStaleLogAt: CFAbsoluteTime = 0
24
- private var pressedKeyCodes = Set<Int64>()
24
+ private var pressedKeyCodes: [Int64: CFAbsoluteTime] = [:]
25
25
  private let breaker = EventTapBreaker(label: "KeyboardRemap")
26
26
  private let budgetMeter = TapBudgetMeter(label: "KeyboardRemap")
27
27
  private let maxCapsLayerIdleDuration: TimeInterval = 2.0
28
28
  private let maxCapsLayerHeldDuration: TimeInterval = 20.0
29
+ private let maxTrackedKeyDownDuration: TimeInterval = 120.0
29
30
  private let emergencyBypassDuration: TimeInterval = 3.0
30
31
 
31
32
  private init() {
@@ -182,7 +183,8 @@ final class KeyboardRemapController: ObservableObject {
182
183
  return Unmanaged.passUnretained(event)
183
184
  }
184
185
 
185
- updatePressedKeys(type: type, keyCode: event.getIntegerValueField(.keyboardEventKeycode))
186
+ expireStalePressedKeys(now: started)
187
+ updatePressedKeys(type: type, keyCode: event.getIntegerValueField(.keyboardEventKeycode), now: started)
186
188
  if shouldTriggerEmergencyReset(type: type, event: event) {
187
189
  emergencyClear(now: started)
188
190
  InputCaptureResetCenter.reset(reason: "keyboard emergency chord")
@@ -295,23 +297,32 @@ final class KeyboardRemapController: ObservableObject {
295
297
  DiagnosticLog.shared.warn("KeyboardRemap: emergency bypass via Escape")
296
298
  }
297
299
 
298
- private func updatePressedKeys(type: CGEventType, keyCode: Int64) {
300
+ private func updatePressedKeys(type: CGEventType, keyCode: Int64, now: CFAbsoluteTime) {
299
301
  switch type {
300
302
  case .keyDown:
301
- pressedKeyCodes.insert(keyCode)
303
+ pressedKeyCodes[keyCode] = now
302
304
  case .keyUp:
303
- pressedKeyCodes.remove(keyCode)
305
+ pressedKeyCodes.removeValue(forKey: keyCode)
304
306
  default:
305
307
  break
306
308
  }
307
309
  }
308
310
 
311
+ private func expireStalePressedKeys(now: CFAbsoluteTime) {
312
+ let staleKeys = pressedKeyCodes.filter { now - $0.value > maxTrackedKeyDownDuration }.map(\.key)
313
+ guard !staleKeys.isEmpty else { return }
314
+ for keyCode in staleKeys {
315
+ pressedKeyCodes.removeValue(forKey: keyCode)
316
+ }
317
+ DiagnosticLog.shared.warn("KeyboardRemap: cleared stale key-down state for \(staleKeys.count) key(s)")
318
+ }
319
+
309
320
  private func shouldTriggerEmergencyReset(type: CGEventType, event: CGEvent) -> Bool {
310
321
  guard type == .keyDown else { return false }
311
322
  let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
312
323
  let flags = event.flags
313
324
  return keyCode == 40
314
- && pressedKeyCodes.contains(53)
325
+ && pressedKeyCodes[53] != nil
315
326
  && flags.contains(.maskShift)
316
327
  }
317
328
 
@@ -93,6 +93,7 @@ final class MouseGestureController: ObservableObject {
93
93
 
94
94
  private var eventTap: CFMachPort?
95
95
  private var runLoopSource: CFRunLoopSource?
96
+ private var installedEventMask: CGEventMask = 0
96
97
  private var session: GestureSession?
97
98
  private var retainedOverlays: [ObjectIdentifier: MouseGestureOverlay] = [:]
98
99
  private var staleSessionTimer: Timer?
@@ -106,6 +107,9 @@ final class MouseGestureController: ObservableObject {
106
107
  let buttonNumber: Int64
107
108
  let startPoint: CGPoint
108
109
  let nativeClickPassthrough: Bool
110
+ var nativeClickBalanced: Bool
111
+ let dragThreshold: CGFloat
112
+ let axisBias: CGFloat
109
113
  let startedAt: CFAbsoluteTime
110
114
  }
111
115
 
@@ -139,14 +143,11 @@ final class MouseGestureController: ObservableObject {
139
143
  return tapTrackingState
140
144
  }
141
145
 
142
- private func currentTrackingButton() -> Int64? {
143
- currentTrackingState()?.buttonNumber
144
- }
145
-
146
146
  private func setTrackingButton(
147
147
  _ value: Int64?,
148
148
  startPoint: CGPoint = .zero,
149
- nativeClickPassthrough: Bool = false
149
+ nativeClickPassthrough: Bool = false,
150
+ tuning: MouseShortcutTuning = .defaults
150
151
  ) {
151
152
  trackingLock.lock()
152
153
  if let value {
@@ -154,6 +155,9 @@ final class MouseGestureController: ObservableObject {
154
155
  buttonNumber: value,
155
156
  startPoint: startPoint,
156
157
  nativeClickPassthrough: nativeClickPassthrough,
158
+ nativeClickBalanced: !nativeClickPassthrough,
159
+ dragThreshold: tuning.dragThreshold,
160
+ axisBias: tuning.axisBias,
157
161
  startedAt: CFAbsoluteTimeGetCurrent()
158
162
  )
159
163
  } else {
@@ -162,6 +166,21 @@ final class MouseGestureController: ObservableObject {
162
166
  trackingLock.unlock()
163
167
  }
164
168
 
169
+ private func markNativeClickBalanced(buttonNumber: Int64) -> Bool {
170
+ trackingLock.lock()
171
+ defer { trackingLock.unlock() }
172
+ guard var state = tapTrackingState,
173
+ state.buttonNumber == buttonNumber,
174
+ state.nativeClickPassthrough,
175
+ !state.nativeClickBalanced else {
176
+ return false
177
+ }
178
+
179
+ state.nativeClickBalanced = true
180
+ tapTrackingState = state
181
+ return true
182
+ }
183
+
165
184
  private init() {
166
185
  breaker.onStateChanged = { [weak self] newState in
167
186
  self?.breakerState = newState
@@ -238,10 +257,16 @@ final class MouseGestureController: ObservableObject {
238
257
  return
239
258
  }
240
259
 
260
+ let desiredMask = desiredEventMask()
241
261
  if eventTap == nil {
242
- installEventTap()
262
+ installEventTap(mask: desiredMask)
243
263
  } else if let eventTap {
244
- CGEvent.tapEnable(tap: eventTap, enable: true)
264
+ if installedEventMask != desiredMask {
265
+ removeEventTap()
266
+ installEventTap(mask: desiredMask)
267
+ } else {
268
+ CGEvent.tapEnable(tap: eventTap, enable: true)
269
+ }
245
270
  }
246
271
  }
247
272
 
@@ -256,20 +281,18 @@ final class MouseGestureController: ObservableObject {
256
281
  }
257
282
  }
258
283
 
259
- private func installEventTap() {
260
- // Fresh install is a clean slate — drop any stale trip history so
261
- // the new tap's first failure is judged on its own merits.
262
- breaker.reset()
263
-
284
+ private func desiredEventMask() -> CGEventMask {
264
285
  var mask = CGEventMask(0)
265
- mask |= CGEventMask(1) << CGEventType.leftMouseDown.rawValue
266
- mask |= CGEventMask(1) << CGEventType.leftMouseUp.rawValue
267
- mask |= CGEventMask(1) << CGEventType.rightMouseDown.rawValue
268
- mask |= CGEventMask(1) << CGEventType.rightMouseDragged.rawValue
269
- mask |= CGEventMask(1) << CGEventType.rightMouseUp.rawValue
270
286
  mask |= CGEventMask(1) << CGEventType.otherMouseDown.rawValue
271
287
  mask |= CGEventMask(1) << CGEventType.otherMouseDragged.rawValue
272
288
  mask |= CGEventMask(1) << CGEventType.otherMouseUp.rawValue
289
+ return mask
290
+ }
291
+
292
+ private func installEventTap(mask: CGEventMask) {
293
+ // Fresh install is a clean slate — drop any stale trip history so
294
+ // the new tap's first failure is judged on its own merits.
295
+ breaker.reset()
273
296
 
274
297
  let tap = CGEvent.tapCreate(
275
298
  tap: .cgSessionEventTap,
@@ -288,6 +311,7 @@ final class MouseGestureController: ObservableObject {
288
311
  let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
289
312
  eventTap = tap
290
313
  runLoopSource = source
314
+ installedEventMask = mask
291
315
 
292
316
  if let source {
293
317
  EventTapThread.shared.add(source: source)
@@ -309,6 +333,7 @@ final class MouseGestureController: ObservableObject {
309
333
  CFMachPortInvalidate(tap)
310
334
  }
311
335
  eventTap = nil
336
+ installedEventMask = 0
312
337
  }
313
338
 
314
339
  private static let eventTapCallback: CGEventTapCallBack = { _, type, event, userInfo in
@@ -352,19 +377,6 @@ final class MouseGestureController: ObservableObject {
352
377
  return Unmanaged.passUnretained(event)
353
378
  }
354
379
 
355
- switch type {
356
- case .leftMouseDown, .leftMouseUp:
357
- return handlePassiveMouseButtonEvent(type: type, event: event)
358
- case .rightMouseDown:
359
- return handleMouseDown(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
360
- case .rightMouseDragged:
361
- return handleMouseDragged(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
362
- case .rightMouseUp:
363
- return handleMouseUp(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
364
- default:
365
- break
366
- }
367
-
368
380
  let buttonNumber = event.getIntegerValueField(.mouseEventButtonNumber)
369
381
  if buttonNumber < 2 {
370
382
  return Unmanaged.passUnretained(event)
@@ -389,54 +401,15 @@ final class MouseGestureController: ObservableObject {
389
401
  // MouseEventSnapshot, and hand the heavy work to main async — so a slow
390
402
  // main thread never adds latency to mouse events at the head-insert tap.
391
403
 
392
- private func handlePassiveMouseButtonEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
393
- let snapshot = MouseEventSnapshot(
394
- location: event.location,
395
- flags: event.flags,
396
- buttonNumber: event.getIntegerValueField(.mouseEventButtonNumber)
397
- )
398
- DispatchQueue.main.async { [weak self] in
399
- self?.processPassiveMouseButton(type: type, snapshot: snapshot)
400
- }
401
- return Unmanaged.passUnretained(event)
402
- }
403
-
404
404
  private func isEmergencyMouseReset(type: CGEventType, event: CGEvent) -> Bool {
405
405
  switch type {
406
- case .leftMouseDown, .rightMouseDown, .otherMouseDown:
406
+ case .otherMouseDown:
407
407
  return event.flags.intersection(.latticesHyper) == .latticesHyper
408
408
  default:
409
409
  return false
410
410
  }
411
411
  }
412
412
 
413
- private func processPassiveMouseButton(type: CGEventType, snapshot: MouseEventSnapshot) {
414
- dispatchPrecondition(condition: .onQueue(.main))
415
- guard MouseInputEventViewer.shared.isCaptureActive else { return }
416
-
417
- let phase: String
418
- switch type {
419
- case .leftMouseDown, .rightMouseDown:
420
- phase = "down"
421
- case .leftMouseUp, .rightMouseUp:
422
- phase = "up"
423
- default:
424
- return
425
- }
426
-
427
- recordObservedEvent(
428
- phase: phase,
429
- button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
430
- location: snapshot.location,
431
- delta: .zero,
432
- modifiers: snapshot.flags,
433
- candidate: nil,
434
- match: nil,
435
- note: "pass-through primary button",
436
- appInfo: currentAppInfo()
437
- )
438
- }
439
-
440
413
  private func handleMouseDown(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
441
414
  let snapshot = MouseEventSnapshot(
442
415
  location: event.location,
@@ -448,6 +421,7 @@ final class MouseGestureController: ObservableObject {
448
421
  let button = MouseShortcutButton(rawButtonNumber: Int(buttonNumber))
449
422
  let needsNativeClickCapture = MouseShortcutStore.shared.hasEnabledRule(button: button, kind: .click)
450
423
  || MouseShortcutStore.shared.hasEnabledRule(button: button, kind: .shape)
424
+ let tuning = MouseShortcutStore.shared.tuning
451
425
  let nativeClickPassthrough = buttonNumber >= 2 && !needsNativeClickCapture
452
426
  let canRecognize = Preferences.shared.mouseGesturesEnabled
453
427
  && MouseShortcutStore.shared.watchedButtonNumbers.contains(buttonNumber)
@@ -472,7 +446,8 @@ final class MouseGestureController: ObservableObject {
472
446
  setTrackingButton(
473
447
  buttonNumber,
474
448
  startPoint: snapshot.location,
475
- nativeClickPassthrough: nativeClickPassthrough
449
+ nativeClickPassthrough: nativeClickPassthrough,
450
+ tuning: tuning
476
451
  )
477
452
  DispatchQueue.main.async { [weak self] in
478
453
  self?.processMouseDownConsume(
@@ -572,7 +547,8 @@ final class MouseGestureController: ObservableObject {
572
547
  }
573
548
 
574
549
  private func handleMouseDragged(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
575
- guard currentTrackingButton() == buttonNumber else {
550
+ guard let trackingState = currentTrackingState(),
551
+ trackingState.buttonNumber == buttonNumber else {
576
552
  return Unmanaged.passUnretained(event)
577
553
  }
578
554
  let snapshot = MouseEventSnapshot(
@@ -580,6 +556,28 @@ final class MouseGestureController: ObservableObject {
580
556
  flags: event.flags,
581
557
  buttonNumber: buttonNumber
582
558
  )
559
+
560
+ let delta = CGPoint(
561
+ x: snapshot.location.x - trackingState.startPoint.x,
562
+ y: snapshot.location.y - trackingState.startPoint.y
563
+ )
564
+ let direction = Self.resolveDirection(
565
+ delta: delta,
566
+ threshold: trackingState.dragThreshold,
567
+ axisBias: trackingState.axisBias
568
+ )
569
+ if direction != nil,
570
+ markNativeClickBalanced(buttonNumber: buttonNumber) {
571
+ postSyntheticMouseUp(
572
+ buttonNumber: buttonNumber,
573
+ at: snapshot.location,
574
+ flags: snapshot.flags
575
+ )
576
+ DispatchQueue.main.async {
577
+ DiagnosticLog.shared.info("MouseGesture: balanced native mouseUp for claimed gesture button=\(buttonNumber)")
578
+ }
579
+ }
580
+
583
581
  DispatchQueue.main.async { [weak self] in
584
582
  self?.processMouseDragged(snapshot: snapshot)
585
583
  }
@@ -677,8 +675,11 @@ final class MouseGestureController: ObservableObject {
677
675
  x: snapshot.location.x - trackingState.startPoint.x,
678
676
  y: snapshot.location.y - trackingState.startPoint.y
679
677
  )
680
- let tuning = MouseShortcutStore.shared.tuning
681
- let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
678
+ let direction = Self.resolveDirection(
679
+ delta: delta,
680
+ threshold: trackingState.dragThreshold,
681
+ axisBias: trackingState.axisBias
682
+ )
682
683
  if direction == nil {
683
684
  setTrackingButton(nil)
684
685
  DispatchQueue.main.async { [weak self] in
@@ -982,13 +983,7 @@ final class MouseGestureController: ObservableObject {
982
983
  }
983
984
 
984
985
  private func replayMouseClick(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
985
- let events: [CGEventType]
986
- if buttonNumber == Int64(CGMouseButton.right.rawValue) {
987
- events = [.rightMouseDown, .rightMouseUp]
988
- } else {
989
- events = [.otherMouseDown, .otherMouseUp]
990
- }
991
- for type in events {
986
+ for type in [CGEventType.otherMouseDown, .otherMouseUp] {
992
987
  guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)) else { continue }
993
988
  guard let event = CGEvent(
994
989
  mouseEventSource: nil,
@@ -1004,6 +999,23 @@ final class MouseGestureController: ObservableObject {
1004
999
  }
1005
1000
  }
1006
1001
 
1002
+ private func postSyntheticMouseUp(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
1003
+ guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)),
1004
+ let event = CGEvent(
1005
+ mouseEventSource: nil,
1006
+ mouseType: .otherMouseUp,
1007
+ mouseCursorPosition: point,
1008
+ mouseButton: mouseButton
1009
+ ) else {
1010
+ return
1011
+ }
1012
+
1013
+ event.setIntegerValueField(CGEventField.mouseEventButtonNumber, value: buttonNumber)
1014
+ event.setIntegerValueField(CGEventField.eventSourceUserData, value: Self.syntheticMarker)
1015
+ event.flags = flags
1016
+ event.post(tap: CGEventTapLocation.cghidEventTap)
1017
+ }
1018
+
1007
1019
  private func screen(containing cgPoint: CGPoint) -> NSScreen? {
1008
1020
  let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
1009
1021
  let nsPoint = NSPoint(x: cgPoint.x, y: primaryHeight - cgPoint.y)
@@ -125,6 +125,7 @@ final class ScreenOverlayCanvasController {
125
125
  private var globalDismissMonitor: Any?
126
126
  private var localDismissMonitor: Any?
127
127
  private var dragState: OverlayActorDragState?
128
+ private var actorDragTimeoutTimer: Timer?
128
129
  private var agentActorsHidden = false
129
130
  private let maxActorDragDuration: TimeInterval = 8.0
130
131
 
@@ -176,6 +177,7 @@ final class ScreenOverlayCanvasController {
176
177
  motionsByLayerID.removeValue(forKey: id)
177
178
  if dragState?.id == id {
178
179
  dragState = nil
180
+ cancelActorDragTimeout()
179
181
  resetPointerCapture()
180
182
  }
181
183
  render()
@@ -188,6 +190,7 @@ final class ScreenOverlayCanvasController {
188
190
  motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
189
191
  if let dragState, removedIDs.contains(dragState.id) {
190
192
  self.dragState = nil
193
+ cancelActorDragTimeout()
191
194
  resetPointerCapture()
192
195
  }
193
196
  render()
@@ -198,6 +201,7 @@ final class ScreenOverlayCanvasController {
198
201
  agentActorsHidden.toggle()
199
202
  if agentActorsHidden {
200
203
  dragState = nil
204
+ cancelActorDragTimeout()
201
205
  resetPointerCapture()
202
206
  }
203
207
  render()
@@ -206,6 +210,7 @@ final class ScreenOverlayCanvasController {
206
210
 
207
211
  func resetInputCapture(reason: String) {
208
212
  dragState = nil
213
+ cancelActorDragTimeout()
209
214
  resetPointerCapture()
210
215
  DiagnosticLog.shared.warn("ScreenOverlay: input capture reset for \(reason)")
211
216
  }
@@ -366,30 +371,19 @@ final class ScreenOverlayCanvasController {
366
371
  let hasAgentLayer = layersByID.values.contains { $0.owner == .agentApi }
367
372
  if hasAgentLayer, globalDismissMonitor == nil {
368
373
  let mask: NSEvent.EventTypeMask = [
369
- .mouseMoved,
370
374
  .leftMouseDown,
371
- .leftMouseUp,
372
375
  .rightMouseDown,
373
376
  .otherMouseDown,
374
- .leftMouseDragged,
375
- .rightMouseDragged,
376
- .otherMouseDragged,
377
377
  ]
378
378
  globalDismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in
379
379
  DispatchQueue.main.async {
380
380
  _ = self?.handlePointerEvent(event)
381
381
  }
382
382
  }
383
- localDismissMonitor = NSEvent.addLocalMonitorForEvents(matching: mask.union(.keyDown)) { [weak self] event in
384
- if event.type == .keyDown {
385
- if event.keyCode == 53 {
386
- self?.dismissAgentOverlays()
387
- return nil
388
- }
389
- } else {
390
- if self?.handlePointerEvent(event) == true {
391
- return nil
392
- }
383
+ localDismissMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
384
+ if event.keyCode == 53 {
385
+ self?.dismissAgentOverlays()
386
+ return nil
393
387
  }
394
388
  return event
395
389
  }
@@ -403,6 +397,7 @@ final class ScreenOverlayCanvasController {
403
397
  self.localDismissMonitor = nil
404
398
  }
405
399
  dragState = nil
400
+ cancelActorDragTimeout()
406
401
  resetPointerCapture()
407
402
  }
408
403
  }
@@ -410,35 +405,7 @@ final class ScreenOverlayCanvasController {
410
405
  @discardableResult
411
406
  private func handlePointerEvent(_ event: NSEvent) -> Bool {
412
407
  switch event.type {
413
- case .mouseMoved:
414
- updatePointerCapture(at: NSEvent.mouseLocation)
415
- return false
416
- case .leftMouseDown:
417
- updatePointerCapture(at: NSEvent.mouseLocation)
418
- if beginActorDrag(at: NSEvent.mouseLocation) {
419
- return true
420
- }
421
- dismissAgentOverlays()
422
- return false
423
- case .leftMouseDragged:
424
- if dragActor(to: NSEvent.mouseLocation) {
425
- return true
426
- }
427
- dismissAgentOverlays()
428
- return false
429
- case .leftMouseUp:
430
- let wasDragging = dragState != nil
431
- endActorDrag()
432
- updatePointerCapture(at: NSEvent.mouseLocation)
433
- return wasDragging
434
- case .rightMouseDown:
435
- updatePointerCapture(at: NSEvent.mouseLocation)
436
- if closeActor(at: NSEvent.mouseLocation) {
437
- return true
438
- }
439
- dismissAgentOverlays()
440
- return false
441
- case .otherMouseDown, .rightMouseDragged, .otherMouseDragged:
408
+ case .leftMouseDown, .rightMouseDown, .otherMouseDown:
442
409
  dismissAgentOverlays()
443
410
  return false
444
411
  default:
@@ -447,16 +414,7 @@ final class ScreenOverlayCanvasController {
447
414
  }
448
415
 
449
416
  private func updatePointerCapture(at globalPoint: CGPoint) {
450
- clearStaleActorDragIfNeeded()
451
- let captureWindow: ScreenOverlayWindow?
452
- if let dragState {
453
- captureWindow = windowsByScreenID[dragState.screenID]
454
- } else {
455
- captureWindow = hitActor(at: globalPoint)?.window
456
- }
457
- for window in windowsByScreenID.values {
458
- window.ignoresMouseEvents = window !== captureWindow
459
- }
417
+ resetPointerCapture()
460
418
  }
461
419
 
462
420
  private func beginActorDrag(at globalPoint: CGPoint) -> Bool {
@@ -472,6 +430,7 @@ final class ScreenOverlayCanvasController {
472
430
  lastPoint: currentPoint,
473
431
  startedAt: Date()
474
432
  )
433
+ scheduleActorDragTimeout()
475
434
  layersByID[hit.id] = layer.replacingPayload(.pet(payload.moved(to: currentPoint, state: "idle", isDragging: true)))
476
435
  render()
477
436
  updateLifecycleMonitors()
@@ -505,14 +464,16 @@ final class ScreenOverlayCanvasController {
505
464
  let layer = layersByID[dragState.id],
506
465
  case .pet(let payload) = layer.payload else {
507
466
  self.dragState = nil
467
+ cancelActorDragTimeout()
508
468
  resetPointerCapture()
509
469
  return
510
470
  }
511
471
  layersByID[dragState.id] = layer.replacingPayload(.pet(payload.moved(to: dragState.lastPoint, state: "idle", isDragging: false)))
512
472
  self.dragState = nil
473
+ cancelActorDragTimeout()
513
474
  render()
514
475
  updateLifecycleMonitors()
515
- updatePointerCapture(at: NSEvent.mouseLocation)
476
+ resetPointerCapture()
516
477
  }
517
478
 
518
479
  private func clearStaleActorDragIfNeeded() {
@@ -522,6 +483,20 @@ final class ScreenOverlayCanvasController {
522
483
  endActorDrag()
523
484
  }
524
485
 
486
+ private func scheduleActorDragTimeout() {
487
+ cancelActorDragTimeout()
488
+ actorDragTimeoutTimer = Timer.scheduledTimer(withTimeInterval: maxActorDragDuration, repeats: false) { [weak self] _ in
489
+ guard let self, self.dragState != nil else { return }
490
+ DiagnosticLog.shared.warn("ScreenOverlay: actor drag timed out; releasing pointer capture")
491
+ self.endActorDrag()
492
+ }
493
+ }
494
+
495
+ private func cancelActorDragTimeout() {
496
+ actorDragTimeoutTimer?.invalidate()
497
+ actorDragTimeoutTimer = nil
498
+ }
499
+
525
500
  private func resetPointerCapture() {
526
501
  for window in windowsByScreenID.values {
527
502
  window.ignoresMouseEvents = true
@@ -536,10 +511,11 @@ final class ScreenOverlayCanvasController {
536
511
  motionsByLayerID.removeValue(forKey: hit.id)
537
512
  if dragState?.id == hit.id {
538
513
  dragState = nil
514
+ cancelActorDragTimeout()
539
515
  }
540
516
  render()
541
517
  updateLifecycleMonitors()
542
- updatePointerCapture(at: globalPoint)
518
+ resetPointerCapture()
543
519
  return true
544
520
  }
545
521
 
@@ -569,6 +545,7 @@ final class ScreenOverlayCanvasController {
569
545
  motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
570
546
  if let dragState, layersByID[dragState.id] == nil {
571
547
  self.dragState = nil
548
+ cancelActorDragTimeout()
572
549
  resetPointerCapture()
573
550
  }
574
551
  guard layersByID.count != before else { return }
@@ -4,7 +4,7 @@ export default {
4
4
  name: 'lattices',
5
5
  tagline: 'macOS developer workspace manager — tmux sessions with a native menu bar app for tiling, navigation, and project management',
6
6
  type: 'cli-tool',
7
- version: '0.4.11',
7
+ version: '0.4.13',
8
8
  },
9
9
 
10
10
  agent: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lattices/cli",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "The agentic workspace manager for macOS — turn your desktop into a coherent API",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "engines": {