From b7b3e232074e7f3050583a97cbb9d8852e7639d2 Mon Sep 17 00:00:00 2001 From: Xogium Date: Wed, 20 May 2026 19:40:18 +0200 Subject: [PATCH] fix(auto-away): stop spurious away toggling and restore on input Replace unreliable IOKit HIDIdleTime reads with CGEventSource keyboard and mouse idle tracking. Failed samples no longer clear away as fake activity; return requires a sharp idle drop from a tracked peak after a short post-activation grace period. Poll every 0.5s while away so keypresses restore Available promptly. Co-authored-by: Cursor --- .../Models/TeamTalkStatusMode.swift | 4 + ...mTalkConnectionController+Connection.swift | 5 +- ...eamTalkConnectionController+Identity.swift | 83 ++++++++++++------- .../TeamTalkConnectionController.swift | 2 + 4 files changed, 63 insertions(+), 31 deletions(-) diff --git a/App/ttaccessible/Models/TeamTalkStatusMode.swift b/App/ttaccessible/Models/TeamTalkStatusMode.swift index d7387eb..2248af7 100644 --- a/App/ttaccessible/Models/TeamTalkStatusMode.swift +++ b/App/ttaccessible/Models/TeamTalkStatusMode.swift @@ -14,6 +14,10 @@ enum TeamTalkStatusMode: Int32, CaseIterable, Equatable { private static let modeMask: Int32 = 0x000000FF + static func isAwayStatus(_ bitmask: Int32) -> Bool { + (bitmask & modeMask) == away.rawValue + } + init(bitmask: Int32) { switch bitmask & Self.modeMask { case Self.away.rawValue: diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift b/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift index 7f067c2..89bcbba 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+Connection.swift @@ -446,8 +446,9 @@ extension TeamTalkConnectionController { } } let now = CFAbsoluteTimeGetCurrent() + let autoAwayPollInterval = isAutoAwayActive ? 0.5 : 5.0 if connectedRecord != nil, - now - lastAutoAwayCheckTime >= 5.0 { + now - lastAutoAwayCheckTime >= autoAwayPollInterval { lastAutoAwayCheckTime = now if updateAutoAwayIfNeededLocked(instance: instance) { publishInvalidation = .all @@ -825,7 +826,9 @@ extension TeamTalkConnectionController { teamTalkVirtualInputReady = false advancedMicrophoneTargetFormat = nil isAutoAwayActive = false + autoAwayActivationTime = nil autoAwayRestoreStatusMessage = "" + autoAwayPeakIdleSeconds = nil } // MARK: - Error helpers diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+Identity.swift b/App/ttaccessible/Services/TeamTalkConnectionController+Identity.swift index 44072e4..fc53c30 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+Identity.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+Identity.swift @@ -5,8 +5,8 @@ // Created by Mathieu Martin on 30/03/2026. // +import CoreGraphics import Foundation -import IOKit extension TeamTalkConnectionController { @@ -146,29 +146,16 @@ extension TeamTalkConnectionController { isAutoAwayActive = false autoAwayActivationTime = nil autoAwayRestoreStatusMessage = "" + autoAwayPeakIdleSeconds = nil } - func currentIdleSecondsLocked() -> Double { - var iterator: io_iterator_t = 0 - guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOHIDSystem"), &iterator) == KERN_SUCCESS else { - return 0 - } - defer { IOObjectRelease(iterator) } - - let entry = IOIteratorNext(iterator) - guard entry != 0 else { - return 0 - } - defer { IOObjectRelease(entry) } - - var properties: Unmanaged? - guard IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS, - let dictionary = properties?.takeRetainedValue() as NSDictionary?, - let idleTime = dictionary["HIDIdleTime"] as? NSNumber else { - return 0 - } - - return Double(idleTime.uint64Value) / 1_000_000_000 + /// Seconds since last keyboard/mouse input, or nil when the value cannot be read reliably. + func currentIdleSecondsLocked() -> Double? { + let eventTypes: [CGEventType] = [.keyDown, .leftMouseDown, .rightMouseDown] + let samples = eventTypes.map { + CGEventSource.secondsSinceLastEventType(.hidSystemState, eventType: $0) + }.filter { $0.isFinite && $0 >= 0 } + return samples.min() } func updateAutoAwayIfNeededLocked(instance: UnsafeMutableRawPointer) -> Bool { @@ -195,19 +182,22 @@ extension TeamTalkConnectionController { } let currentMode = TeamTalkStatusMode(bitmask: currentUser.nStatusMode) - if isAutoAwayActive, currentMode != .away { + if isAutoAwayActive, !TeamTalkStatusMode.isAwayStatus(currentUser.nStatusMode) { + // User left Away manually; stop managing auto-away. clearAutoAwayStateLocked() return false } - let idleSeconds = currentIdleSecondsLocked() + guard let idleSeconds = currentIdleSecondsLocked() else { + return false + } let threshold = Double(timeoutMinutes * 60) if isAutoAwayActive { - guard idleSeconds < 10 else { - return false - } - return deactivateAutoAwayLocked(instance: instance) + return updateAutoAwayWhileActiveLocked( + instance: instance, + idleSeconds: idleSeconds + ) } guard currentMode == .available, idleSeconds >= threshold else { @@ -230,6 +220,7 @@ extension TeamTalkConnectionController { try waitForCommandCompletionLocked(instance: instance, commandID: commandID) isAutoAwayActive = true autoAwayActivationTime = Date() + autoAwayPeakIdleSeconds = idleSeconds appendAutoAwayActivatedHistoryLocked() return true } catch { @@ -238,6 +229,40 @@ extension TeamTalkConnectionController { } } + /// Returns true when auto-away should end because the user is active again. + func updateAutoAwayWhileActiveLocked( + instance: UnsafeMutableRawPointer, + idleSeconds: Double + ) -> Bool { + let activityThreshold = 3.0 + let minimumIdleDrop = 15.0 + let postActivationGrace: TimeInterval = 2 + + if let peak = autoAwayPeakIdleSeconds { + autoAwayPeakIdleSeconds = max(peak, idleSeconds) + } else { + autoAwayPeakIdleSeconds = idleSeconds + } + + guard idleSeconds < activityThreshold else { + return false + } + + // Ignore brief idle glitches right after we set Away (status/UI updates). + if let activationTime = autoAwayActivationTime, + Date().timeIntervalSince(activationTime) < postActivationGrace { + return false + } + + // Real input resets the idle counter from a much higher sustained value. + guard let peakIdle = autoAwayPeakIdleSeconds, + peakIdle - idleSeconds >= minimumIdleDrop else { + return false + } + + return deactivateAutoAwayLocked(instance: instance) + } + func deactivateAutoAwayLocked(instance: UnsafeMutableRawPointer) -> Bool { guard isAutoAwayActive, let currentUser = currentUserLocked(instance: instance) else { clearAutoAwayStateLocked() @@ -248,7 +273,6 @@ extension TeamTalkConnectionController { let restoredBitmask = TeamTalkStatusMode.available.merged(with: currentUser.nStatusMode) let commandID = restoredMessage.withCString { TT_DoChangeStatus(instance, restoredBitmask, $0) } guard commandID > 0 else { - clearAutoAwayStateLocked() return false } @@ -258,7 +282,6 @@ extension TeamTalkConnectionController { appendAutoAwayDeactivatedHistoryLocked() return true } catch { - clearAutoAwayStateLocked() return false } } diff --git a/App/ttaccessible/Services/TeamTalkConnectionController.swift b/App/ttaccessible/Services/TeamTalkConnectionController.swift index 6f5ade4..70f50a2 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController.swift @@ -132,6 +132,8 @@ final class TeamTalkConnectionController { var isAutoAwayActive = false var autoAwayActivationTime: Date? var autoAwayRestoreStatusMessage = "" + /// Highest HID idle time observed since auto-away activated (input resets pull this down). + var autoAwayPeakIdleSeconds: Double? var pendingUserAccounts: [UserAccountProperties] = [] var cachedUserAccounts: [UserAccountProperties] = [] var listUserAccountsCmdID: Int32 = -1