Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions App/ttaccessible/Models/TeamTalkStatusMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -825,7 +826,9 @@ extension TeamTalkConnectionController {
teamTalkVirtualInputReady = false
advancedMicrophoneTargetFormat = nil
isAutoAwayActive = false
autoAwayActivationTime = nil
autoAwayRestoreStatusMessage = ""
autoAwayPeakIdleSeconds = nil
}

// MARK: - Error helpers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// Created by Mathieu Martin on 30/03/2026.
//

import CoreGraphics
import Foundation
import IOKit

extension TeamTalkConnectionController {

Expand Down Expand Up @@ -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<CFMutableDictionary>?
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 {
Expand All @@ -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 {
Expand All @@ -230,6 +220,7 @@ extension TeamTalkConnectionController {
try waitForCommandCompletionLocked(instance: instance, commandID: commandID)
isAutoAwayActive = true
autoAwayActivationTime = Date()
autoAwayPeakIdleSeconds = idleSeconds
appendAutoAwayActivatedHistoryLocked()
return true
} catch {
Expand All @@ -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()
Expand All @@ -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
}

Expand All @@ -258,7 +282,6 @@ extension TeamTalkConnectionController {
appendAutoAwayDeactivatedHistoryLocked()
return true
} catch {
clearAutoAwayStateLocked()
return false
}
}
Expand Down
2 changes: 2 additions & 0 deletions App/ttaccessible/Services/TeamTalkConnectionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down