Skip to content

Commit 043d4b2

Browse files
authored
Merge pull request #1222 from nextcloud/feat/idle-detection
feat(user-status): add system activity and system lock check on online/away status update
2 parents f9779e2 + c7dd43f commit 043d4b2

File tree

12 files changed

+304
-121
lines changed

12 files changed

+304
-121
lines changed

src/main.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ ipcMain.on('app:relaunch', () => {
103103
})
104104
ipcMain.handle('app:config:get', (event, key) => getAppConfig(key))
105105
ipcMain.handle('app:config:set', (event, key, value) => setAppConfig(key, value))
106+
ipcMain.on('app:grantUserGesturedPermission', (event, id) => {
107+
return event.sender.executeJavaScript(`document.getElementById('${id}')?.click()`, true)
108+
})
106109
ipcMain.on('app:toggleDevTools', (event) => event.sender.toggleDevTools())
107110
ipcMain.handle('app:anything', () => { /* Put any code here to run it from UI */ })
108111
ipcMain.on('app:openChromeWebRtcInternals', () => openChromeWebRtcInternals())

src/preload.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ const TALK_DESKTOP = {
108108
* @param {(event: import('electron').IpcRedererEvent, payload: { key: string, value: unknown, appConfig: import('./app/AppConfig.ts').AppConfig}) => void} callback - Callback
109109
*/
110110
onAppConfigChange: (callback) => ipcRenderer.on('app:config:change', callback),
111+
/**
112+
* Grant a permission requiring a user gesture
113+
*
114+
* @param {string} id - Button ID to click on
115+
*/
116+
grantUserGesturedPermission: (id) => ipcRenderer.send('app:grantUserGesturedPermission', id),
111117
/**
112118
* Trigger download of a URL
113119
*
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/**
7+
* Grant a permission requiring a user gesture:
8+
* Create a hidden button that will be clicked by the main process to request the permission.
9+
*
10+
* @param requester - Function to request the permission
11+
* @return - Promise that resolves with the result of the permission request
12+
*/
13+
export async function grantUserGesturedPermission<T>(requester: () => T) {
14+
return new Promise<T>((resolve) => {
15+
const id = `request-user-gestured-permission-${Math.random().toString(36).slice(2, 6)}`
16+
17+
const button = document.createElement('button')
18+
document.body.appendChild(button)
19+
button.id = id
20+
button.className = 'visually-hidden'
21+
button.addEventListener('click', () => {
22+
const result = requester()
23+
button.remove()
24+
resolve(result)
25+
}, { once: true, passive: true })
26+
27+
window.TALK_DESKTOP.grantUserGesturedPermission(id)
28+
})
29+
}

src/shared/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/* eslint-disable @typescript-eslint/no-explicit-any */
7+
8+
export function once<F extends () => any>(func: F): F
9+
export function once<F extends (...args: any[]) => void>(func: F): F
10+
/**
11+
* Singletone function decorator
12+
*
13+
* @param func - Function
14+
*/
15+
export function once<F extends (() => any) | ((...args: any[]) => void)>(func: F): F {
16+
let wasCalled = false
17+
let result: ReturnType<F>
18+
19+
return ((...args: Parameters<F>): ReturnType<F> => {
20+
if (!wasCalled) {
21+
wasCalled = true
22+
result = func(...args)
23+
}
24+
return result
25+
}) as F
26+
}
27+
28+
/* eslint-enable @typescript-eslint/no-explicit-any */

src/talk/renderer/TitleBar/TitleBar.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import { appData } from '../../../app/AppData.js'
1111
import { BUILD_CONFIG } from '../../../shared/build.config.ts'
1212
import { useDevMode } from '../../../shared/useDevMode.ts'
1313
import { useAppConfigStore } from '../Settings/appConfig.store.ts'
14+
import { useHeartbeat } from '../UserStatus/useHeartbeat.ts'
1415
import { useUserStatusStore } from '../UserStatus/userStatus.store.ts'
15-
import { useUserStatusHeartbeat } from '../UserStatus/useUserStatusHeartbeat.ts'
1616
1717
useUserStatusStore()
18-
useUserStatusHeartbeat()
18+
useHeartbeat()
1919
useAppConfigStore()
2020
2121
const channel = __CHANNEL__
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
// See: https://wicg.github.io/idle-detection/#api-idledetector
7+
8+
type UserIdleState = 'active' | 'idle'
9+
10+
type ScreenIdleState = 'locked' | 'unlocked'
11+
12+
type IdleOptions = {
13+
/**
14+
* Min 60_000 ms
15+
*/
16+
threshold?: number
17+
signal?: AbortSignal
18+
}
19+
20+
interface IdleDetector extends EventTarget {
21+
userState: UserIdleState | null
22+
screenState: ScreenIdleState | null
23+
24+
start(options?: IdleOptions): Promise<void>
25+
onchange(callback: () => void): void
26+
}
27+
28+
interface IdleDetectorConstructor {
29+
requestPermission(): Promise<PermissionState>
30+
31+
new (): IdleDetector
32+
prototype: IdleDetector
33+
}
34+
35+
declare const IdleDetector: IdleDetectorConstructor
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createSharedComposable, useEventListener } from '@vueuse/core'
7+
import { onBeforeUnmount, ref } from 'vue'
8+
9+
const WINDOW_ACTIVE_EVENTS: (keyof WindowEventMap)[] = ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel']
10+
11+
/**
12+
* Whether user is idle in the app (away) - interacted with the app (mouse, keyboard, touch) in the last THRESHOLD minutes
13+
* or made document visible
14+
*
15+
* @param threshold - How long user is considered active after interaction in ms, default is 1 minute
16+
*/
17+
export const useAppIdle = createSharedComposable((threshold: number = 60_000) => {
18+
const isIdle = ref(false)
19+
20+
let activityTimeout: number
21+
22+
/**
23+
* Set new isIdle value and start
24+
*
25+
* @param newIsIdle - New isIdle
26+
*/
27+
function setIsIdle(newIsIdle: boolean) {
28+
isIdle.value = newIsIdle
29+
clearTimeout(activityTimeout)
30+
if (!newIsIdle) {
31+
// TODO: separate tsconfig for main process (Node.js Environment) and renderer process (Browser Environment)
32+
activityTimeout = setTimeout(() => setIsIdle(true), threshold) as unknown as number
33+
}
34+
}
35+
36+
useEventListener(WINDOW_ACTIVE_EVENTS, () => {
37+
setIsIdle(false)
38+
})
39+
40+
useEventListener(document, 'visibilitychange', () => {
41+
setIsIdle(document.hidden)
42+
})
43+
44+
setIsIdle(false)
45+
46+
onBeforeUnmount(() => {
47+
clearTimeout(activityTimeout)
48+
})
49+
50+
return isIdle
51+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createSharedComposable } from '@vueuse/core'
7+
import { onBeforeMount, onBeforeUnmount, readonly, ref } from 'vue'
8+
import { grantUserGesturedPermission } from '../../../../shared/grantUserGesturedPermission.ts'
9+
import { once } from '../../../../shared/utils.ts'
10+
11+
/**
12+
* Grant IdleDetector permission
13+
*/
14+
const grantIdleDetectorPermission = once(async () => {
15+
const permission = await grantUserGesturedPermission(() => IdleDetector.requestPermission())
16+
if (permission !== 'granted') {
17+
throw new Error('Unexpected permission denied for IdleDetector')
18+
}
19+
})
20+
21+
/**
22+
* Use IdleDetector API
23+
*
24+
* @param threshold - IdleDetector's threshold
25+
*/
26+
export const useIdleDetector = createSharedComposable((threshold: number = 60_000) => {
27+
const userState = ref<UserIdleState | undefined>(undefined)
28+
const screenState = ref<ScreenIdleState | undefined>(undefined)
29+
const abortController = new AbortController()
30+
31+
onBeforeMount(async () => {
32+
try {
33+
await grantIdleDetectorPermission()
34+
35+
const idleDetector = new IdleDetector()
36+
idleDetector.addEventListener('change', () => {
37+
userState.value = idleDetector.userState!
38+
screenState.value = idleDetector.screenState!
39+
})
40+
await idleDetector.start({
41+
threshold,
42+
signal: abortController.signal,
43+
})
44+
} catch (error) {
45+
console.error('Unexpected error on starting IdleDetector:', error)
46+
}
47+
})
48+
49+
onBeforeUnmount(() => abortController.abort())
50+
51+
return {
52+
screenState: readonly(screenState),
53+
userState: readonly(userState),
54+
}
55+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createSharedComposable } from '@vueuse/core'
7+
import { computed } from 'vue'
8+
import { useAppIdle } from './useAppIdle.ts'
9+
import { useIdleDetector } from './useIdleDetector.ts'
10+
11+
/**
12+
* Whether the user is away or active
13+
*
14+
* @param threshold - How long user is considered active
15+
*/
16+
export const useIsAway = createSharedComposable((threshold = 60_000) => {
17+
const { userState, screenState } = useIdleDetector(threshold)
18+
const isAppIdle = useAppIdle(threshold)
19+
20+
return computed(() => {
21+
return screenState.value === 'locked' // System Locked - the user is away immediately
22+
|| (userState.value !== 'active' && isAppIdle.value) // Check both to cover unavailable IdleDetector
23+
})
24+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createSharedComposable } from '@vueuse/core'
7+
import { onBeforeUnmount, watch } from 'vue'
8+
import { useIsAway } from './composables/useIsAway.ts'
9+
import { useUserStatusStore } from './userStatus.store.ts'
10+
11+
// General notes:
12+
// - Server has INVALIDATE_STATUS_THRESHOLD with 15 minutes, preventing immediate status update on heartbeat request
13+
// See: https://github.com/nextcloud/server/blob/v31.0.5/apps/user_status/lib/Service/StatusService.php
14+
// - However, "online" status has higher priority than "away"
15+
// - Thus:
16+
// - Changing "Away -> Online" is immediate
17+
// - Changing "Online -> Away" has a 15 minutes threshold
18+
// - See: https://github.com/nextcloud/server/blob/v31.0.5/apps/user_status/lib/Service/StatusService.php#L41-L48
19+
// and: https://github.com/nextcloud/server/blob/master/apps/user_status/lib/Listener/UserLiveStatusListener.php#L75-L87
20+
// - This might change in future to have symmetric behavior on heartbeat
21+
22+
/** How often to send the heartbeat. Must be less than 15 min. */
23+
const HEARTBEAT_INTERVAL = 5 * 60 * 1000 // 5 minutes
24+
25+
/** How long user is considered active before going away */
26+
const AWAY_THRESHOLD = 2 * 60 * 1000 // 2 minutes
27+
28+
/**
29+
* Background heartbeat with user status update
30+
*/
31+
export const useHeartbeat = createSharedComposable(() => {
32+
const userStatusStore = useUserStatusStore()
33+
const isAway = useIsAway(AWAY_THRESHOLD)
34+
35+
let heartbeatTimeout: number | undefined
36+
37+
/**
38+
* Send a heartbeat
39+
*/
40+
async function heartbeat() {
41+
try {
42+
await userStatusStore.updateUserStatusWithHeartbeat(isAway.value)
43+
} catch (error) {
44+
console.error('Error on heartbeat:', error)
45+
}
46+
}
47+
48+
/**
49+
* Start heartbeat interval
50+
*/
51+
async function restartHeartbeat() {
52+
if (heartbeatTimeout) {
53+
clearTimeout(heartbeatTimeout)
54+
}
55+
await heartbeat()
56+
// TODO: fix when main and renderer process have separate tsconfig
57+
heartbeatTimeout = setTimeout(heartbeat, HEARTBEAT_INTERVAL) as unknown as number
58+
}
59+
60+
// Restart heartbeat to immediately notify server on state change
61+
watch(isAway, () => {
62+
// Note: both app and system level activity state changes to inactive with a threshold.
63+
// Only lock/unlock state can be changed many times in a short period, but it is unlikely
64+
// Thus it unlikely overloads with heartbeat, no need to debounce
65+
restartHeartbeat()
66+
}, { immediate: true })
67+
68+
onBeforeUnmount(() => {
69+
clearTimeout(heartbeatTimeout)
70+
})
71+
})

0 commit comments

Comments
 (0)