fix(desktop): stop marking users away while active on the machine (#1942)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-15 21:47:15 -07:00
committed by GitHub
co-authored by Brain
parent 4ddb61f7a8
commit e1865cb7f7
10 changed files with 266 additions and 40 deletions
+130 -1
View File
@@ -2,6 +2,27 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "CoreFoundation-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e9889e6db118d49d88d84728d0e964d973a5680befb5f85f55141beea5c20b"
dependencies = [
"libc",
"mach 0.1.2",
]
[[package]]
name = "IOKit-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99696c398cbaf669d2368076bdb3d627fb0ce51a26899d7c61228c5c0af3bf4a"
dependencies = [
"CoreFoundation-sys",
"libc",
"mach 0.1.2",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -1021,6 +1042,7 @@ dependencies = [
"tokio-util",
"toml 0.8.2",
"url",
"user-idle",
"uuid",
"window-vibrancy",
"windows-sys 0.61.2",
@@ -1836,6 +1858,16 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "cstr"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68523903c8ae5aacfa32a0d9ae60cadeb764e1da14ee0d26b1f3089f13a54636"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "ctor"
version = "0.8.0"
@@ -4690,6 +4722,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "mach"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd13ee2dd61cc82833ba05ade5a30bb3d63f7ced605ef827063c63078302de9"
dependencies = [
"libc",
]
[[package]]
name = "mach"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa"
dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -5925,7 +5975,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 2.0.2",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -10890,6 +10940,19 @@ dependencies = [
"url",
]
[[package]]
name = "user-idle"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433621584802937d26ab1b490236d802a9bc6d7176fe913cc12c2e51a790d5a2"
dependencies = [
"CoreFoundation-sys",
"IOKit-sys",
"cstr",
"mach 0.3.2",
"windows-sys 0.48.0",
]
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -11683,6 +11746,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -11734,6 +11806,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -11800,6 +11887,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -11818,6 +11911,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -11836,6 +11935,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -11866,6 +11971,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -11884,6 +11995,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -11902,6 +12019,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -11920,6 +12043,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
+2
View File
@@ -46,10 +46,12 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHa
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
window-vibrancy = "0.6"
user-idle = { version = "0.6", default-features = false }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
user-idle = { version = "0.6", default-features = false }
[dependencies]
atomic-write-file = "0.3"
+2
View File
@@ -27,6 +27,7 @@ pub(crate) mod mesh_llm;
mod messages;
mod notifications;
mod observer_archive;
mod os_idle;
pub mod pairing;
mod personas;
mod prevent_sleep;
@@ -72,6 +73,7 @@ pub use mesh_llm::*;
pub use messages::*;
pub use notifications::*;
pub use observer_archive::*;
pub use os_idle::*;
pub use pairing::*;
pub use personas::*;
pub use prevent_sleep::*;
+16
View File
@@ -0,0 +1,16 @@
/// Seconds since the last OS-wide user input (keyboard/mouse anywhere on the
/// machine), or `None` on platforms without a supported idle API (Linux
/// Wayland). Callers fall back to in-app activity tracking when `None`.
#[tauri::command]
pub fn get_os_idle_seconds() -> Option<u64> {
#[cfg(any(target_os = "macos", windows))]
{
user_idle::UserIdle::get_time()
.ok()
.map(|idle| idle.as_seconds())
}
#[cfg(not(any(target_os = "macos", windows)))]
{
None
}
}
+1
View File
@@ -760,6 +760,7 @@ pub fn run() {
open_project_terminal,
search_users,
get_presence,
get_os_idle_seconds,
get_default_relay_url,
get_legacy_workspace_storage,
is_shared_identity,
+52 -39
View File
@@ -2,18 +2,20 @@ import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { relayClient } from "@/shared/api/relayClient";
import { getOsIdleSeconds } from "@/shared/api/osIdle";
import { getPresence } from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
mergePresenceUpdate,
parseLivePresenceEvent,
presenceQueryWantsPubkey,
resolveAutomaticPresenceStatus,
} from "@/features/presence/lib/presence";
import type { PresenceLookup, PresenceStatus } from "@/shared/api/types";
const PRESENCE_HEARTBEAT_INTERVAL_MS = 30_000;
const PRESENCE_IDLE_TIMEOUT_MS = 5 * 60_000;
const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000;
const PRESENCE_ACTIVITY_THROTTLE_MS = 1_000;
const PRESENCE_TTL_SECONDS = 90;
const PRESENCE_PREFERENCE_STORAGE_KEY = "buzz-presence-preference";
@@ -62,16 +64,11 @@ function writeStoredPresencePreference(
window.localStorage.setItem(presencePreferenceStorageKey(pubkey), preference);
}
function resolveAutomaticPresenceStatus(
isDocumentHidden: boolean,
function resolveAutomaticPresenceStatusSync(
lastActivityAt: number,
now: number,
): PresenceStatus {
if (isDocumentHidden) {
return "away";
}
return now - lastActivityAt >= PRESENCE_IDLE_TIMEOUT_MS ? "away" : "online";
return resolveAutomaticPresenceStatus(null, lastActivityAt, now);
}
export function usePresenceQuery(
@@ -208,27 +205,43 @@ export function usePresenceSession(pubkey?: string) {
const lastActivityAtRef = React.useRef(Date.now());
const [automaticStatus, setAutomaticStatus] = React.useState<PresenceStatus>(
() =>
resolveAutomaticPresenceStatus(
typeof document === "undefined" ? false : document.hidden,
lastActivityAtRef.current,
Date.now(),
),
resolveAutomaticPresenceStatusSync(lastActivityAtRef.current, Date.now()),
);
const automaticStatusRef = React.useRef(automaticStatus);
const skipNextSyncRef = React.useRef<PresenceStatus | null>(null);
const reevaluateAutomaticStatus = React.useEffectEvent(() => {
const next = resolveAutomaticPresenceStatus(
typeof document === "undefined" ? false : document.hidden,
lastActivityAtRef.current,
Date.now(),
);
const applyAutomaticStatus = React.useEffectEvent((next: PresenceStatus) => {
if (next !== automaticStatusRef.current) {
automaticStatusRef.current = next;
setAutomaticStatus(next);
}
});
const reevaluateAutomaticStatus = React.useEffectEvent(() => {
applyAutomaticStatus(
resolveAutomaticPresenceStatusSync(lastActivityAtRef.current, Date.now()),
);
});
// OS-wide idle is authoritative when available; async, so tick-driven only.
const reevaluateFromOsIdle = React.useEffectEvent(async () => {
const osIdleSeconds = await getOsIdleSeconds().catch(() => null);
if (typeof osIdleSeconds === "number") {
// Keep the fallback clock consistent so a later null reading can't flap.
lastActivityAtRef.current = Math.max(
lastActivityAtRef.current,
Date.now() - osIdleSeconds * 1000,
);
}
applyAutomaticStatus(
resolveAutomaticPresenceStatus(
typeof osIdleSeconds === "number" ? osIdleSeconds : null,
lastActivityAtRef.current,
Date.now(),
),
);
});
React.useEffect(() => {
setPresencePreference(readStoredPresencePreference(normalizedPubkey));
lastActivityAtRef.current = Date.now();
@@ -249,37 +262,36 @@ export function usePresenceSession(pubkey?: string) {
return;
}
// Fallback activity signal: wheel/pointermove count (passive reading),
// throttled to 1/s; window visibility never affects presence.
let lastRecordedAt = 0;
function handleUserActivity() {
if (typeof document !== "undefined" && document.hidden) {
const now = Date.now();
if (now - lastRecordedAt < PRESENCE_ACTIVITY_THROTTLE_MS) {
return;
}
lastRecordedAt = now;
recordActivity();
}
function handleFocus() {
recordActivity();
}
function handleVisibilityChange() {
if (!document.hidden) {
recordActivity();
return;
}
// Going hidden can flip the automatic status to away.
reevaluateAutomaticStatus();
}
window.addEventListener("pointerdown", handleUserActivity, true);
window.addEventListener("pointermove", handleUserActivity, true);
window.addEventListener("wheel", handleUserActivity, {
capture: true,
passive: true,
});
window.addEventListener("keydown", handleUserActivity, true);
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleUserActivity);
return () => {
window.removeEventListener("pointerdown", handleUserActivity, true);
window.removeEventListener("pointermove", handleUserActivity, true);
window.removeEventListener("wheel", handleUserActivity, {
capture: true,
});
window.removeEventListener("keydown", handleUserActivity, true);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleUserActivity);
};
}, [normalizedPubkey]);
@@ -288,8 +300,9 @@ export function usePresenceSession(pubkey?: string) {
return;
}
void reevaluateFromOsIdle();
const intervalId = window.setInterval(() => {
reevaluateAutomaticStatus();
void reevaluateFromOsIdle();
}, PRESENCE_STATUS_TICK_INTERVAL_MS);
return () => {
@@ -5,6 +5,8 @@ import {
mergePresenceUpdate,
parseLivePresenceEvent,
presenceQueryWantsPubkey,
PRESENCE_IDLE_TIMEOUT_MS,
resolveAutomaticPresenceStatus,
} from "./presence.ts";
const WILL = "8e39cba681211b3782d0e4483e9343719b9b7be66515252da5491f26421896b1";
@@ -77,3 +79,37 @@ test("live event lowercases the author pubkey", () => {
});
assert.equal(parsed.pubkey, WILL);
});
test("OS idle below the threshold is online even with stale in-app activity", () => {
const now = 1_000_000_000;
const staleActivity = now - 60 * 60_000;
assert.equal(
resolveAutomaticPresenceStatus(30, staleActivity, now),
"online",
);
});
test("OS idle at the threshold is away even with fresh in-app activity", () => {
const now = 1_000_000_000;
assert.equal(resolveAutomaticPresenceStatus(600, now, now), "away");
});
test("without OS idle, fresh in-app activity is online", () => {
const now = 1_000_000_000;
assert.equal(
resolveAutomaticPresenceStatus(null, now - 1_000, now),
"online",
);
});
test("without OS idle, in-app inactivity past the threshold is away", () => {
const now = 1_000_000_000;
assert.equal(
resolveAutomaticPresenceStatus(null, now - PRESENCE_IDLE_TIMEOUT_MS, now),
"away",
);
});
test("OS idle of zero is online", () => {
assert.equal(resolveAutomaticPresenceStatus(0, 0, 1_000_000_000), "online");
});
@@ -36,6 +36,22 @@ export function mergePresenceUpdate(
return { ...old, [pubkey]: status };
}
// Away means "human not at the machine" (Slack/Discord semantics), never
// "Buzz is not the focused window". OS-wide idle is authoritative when the
// platform exposes it; otherwise fall back to in-app activity.
export const PRESENCE_IDLE_TIMEOUT_MS = 10 * 60_000;
export function resolveAutomaticPresenceStatus(
osIdleSeconds: number | null,
lastActivityAt: number,
now: number,
): PresenceStatus {
if (osIdleSeconds !== null) {
return osIdleSeconds * 1000 >= PRESENCE_IDLE_TIMEOUT_MS ? "away" : "online";
}
return now - lastActivityAt >= PRESENCE_IDLE_TIMEOUT_MS ? "away" : "online";
}
export function getPresenceLabel(status: PresenceStatus) {
switch (status) {
case "online":
+7
View File
@@ -0,0 +1,7 @@
import { invokeTauri } from "@/shared/api/tauri";
// Seconds since the last OS-wide user input, or null where the platform has
// no supported idle API (e.g. Linux Wayland).
export function getOsIdleSeconds(): Promise<number | null> {
return invokeTauri<number | null>("get_os_idle_seconds");
}
+4
View File
@@ -8619,6 +8619,10 @@ export function maybeInstallE2eTauriMocks() {
},
activeConfig,
);
case "get_os_idle_seconds":
// e2e runs headless with no OS idle API; the presence hook falls back
// to in-app activity tracking.
return null;
case "get_git_identity":
// Matches the "Thomas P" author on a mock snapshot commit so the
// viewer-identity avatar attribution is exercised in e2e.