feat: Add user statuses and cards
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
setupSystemHandlers,
|
||||
setupWindowControlHandlers
|
||||
} from '../ipc';
|
||||
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||
|
||||
export function registerAppLifecycle(): void {
|
||||
app.whenReady().then(async () => {
|
||||
@@ -34,6 +35,7 @@ export function registerAppLifecycle(): void {
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
await createWindow();
|
||||
startIdleMonitor();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (getMainWindow()) {
|
||||
@@ -57,6 +59,7 @@ export function registerAppLifecycle(): void {
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
shutdownDesktopUpdater();
|
||||
stopIdleMonitor();
|
||||
await cleanupLinuxScreenShareAudioRouting();
|
||||
await destroyDatabase();
|
||||
app.quit();
|
||||
|
||||
124
electron/idle/idle-monitor.spec.ts
Normal file
124
electron/idle/idle-monitor.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach
|
||||
} from 'vitest';
|
||||
|
||||
// Mock Electron modules before importing the module under test
|
||||
const mockGetSystemIdleTime = vi.fn(() => 0);
|
||||
const mockSend = vi.fn();
|
||||
const mockGetMainWindow = vi.fn(() => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend }
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
powerMonitor: {
|
||||
getSystemIdleTime: mockGetSystemIdleTime
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../window/create-window', () => ({
|
||||
getMainWindow: mockGetMainWindow
|
||||
}));
|
||||
|
||||
import {
|
||||
startIdleMonitor,
|
||||
stopIdleMonitor,
|
||||
getIdleState
|
||||
} from './idle-monitor';
|
||||
|
||||
describe('idle-monitor', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockGetSystemIdleTime.mockReturnValue(0);
|
||||
mockSend.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopIdleMonitor();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns active when idle time is below threshold', () => {
|
||||
mockGetSystemIdleTime.mockReturnValue(0);
|
||||
expect(getIdleState()).toBe('active');
|
||||
});
|
||||
|
||||
it('returns idle when idle time exceeds 15 minutes', () => {
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
expect(getIdleState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('sends idle-state-changed to renderer when transitioning to idle', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'idle');
|
||||
});
|
||||
|
||||
it('sends idle-state-changed to renderer when transitioning back to active', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
// Go idle
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
mockSend.mockClear();
|
||||
|
||||
// Go active
|
||||
mockGetSystemIdleTime.mockReturnValue(5);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'active');
|
||||
});
|
||||
|
||||
it('does not fire duplicates when state stays the same', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
// Only one transition, so only one call
|
||||
const idleCalls = mockSend.mock.calls.filter(
|
||||
([channel, state]: [string, string]) => channel === 'idle-state-changed' && state === 'idle'
|
||||
);
|
||||
|
||||
expect(idleCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('stops polling after stopIdleMonitor', () => {
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
mockSend.mockClear();
|
||||
|
||||
stopIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(0);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify when main window is null', () => {
|
||||
mockGetMainWindow.mockReturnValue(null);
|
||||
startIdleMonitor();
|
||||
|
||||
mockGetSystemIdleTime.mockReturnValue(15 * 60);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
mockGetMainWindow.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: mockSend }
|
||||
});
|
||||
});
|
||||
});
|
||||
49
electron/idle/idle-monitor.ts
Normal file
49
electron/idle/idle-monitor.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { powerMonitor } from 'electron';
|
||||
import { getMainWindow } from '../window/create-window';
|
||||
|
||||
const IDLE_THRESHOLD_SECONDS = 15 * 60; // 15 minutes
|
||||
const POLL_INTERVAL_MS = 10_000; // Check every 10 seconds
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let wasIdle = false;
|
||||
|
||||
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||
|
||||
export type IdleState = 'active' | 'idle';
|
||||
|
||||
/**
|
||||
* Starts polling `powerMonitor.getSystemIdleTime()` and notifies the
|
||||
* renderer whenever the user transitions between active and idle.
|
||||
*/
|
||||
export function startIdleMonitor(): void {
|
||||
if (pollTimer)
|
||||
return;
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||
const isIdle = idleSeconds >= IDLE_THRESHOLD_SECONDS;
|
||||
|
||||
if (isIdle !== wasIdle) {
|
||||
wasIdle = isIdle;
|
||||
const state: IdleState = isIdle ? 'idle' : 'active';
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IDLE_STATE_CHANGED_CHANNEL, state);
|
||||
}
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopIdleMonitor(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getIdleState(): IdleState {
|
||||
const idleSeconds = powerMonitor.getSystemIdleTime();
|
||||
|
||||
return idleSeconds >= IDLE_THRESHOLD_SECONDS ? 'idle' : 'active';
|
||||
}
|
||||
@@ -528,6 +528,7 @@ export function setupSystemHandlers(): void {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
response.on('error', () => resolve(false));
|
||||
});
|
||||
|
||||
@@ -537,7 +538,12 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
||||
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const;
|
||||
const allowedCommands = [
|
||||
'cut',
|
||||
'copy',
|
||||
'paste',
|
||||
'selectAll'
|
||||
] as const;
|
||||
|
||||
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
||||
return;
|
||||
@@ -557,4 +563,10 @@ export function setupSystemHandlers(): void {
|
||||
case 'selectAll': webContents.selectAll(); break;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-idle-state', () => {
|
||||
const { getIdleState } = require('../idle/idle-monitor') as typeof import('../idle/idle-monitor');
|
||||
|
||||
return getIdleState();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monit
|
||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -214,6 +215,9 @@ export interface ElectronAPI {
|
||||
contextMenuCommand: (command: string) => Promise<void>;
|
||||
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||
|
||||
getIdleState: () => Promise<'active' | 'idle'>;
|
||||
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
|
||||
|
||||
command: <T = unknown>(command: Command) => Promise<T>;
|
||||
query: <T = unknown>(query: Query) => Promise<T>;
|
||||
}
|
||||
@@ -333,6 +337,19 @@ const electronAPI: ElectronAPI = {
|
||||
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
||||
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||
|
||||
getIdleState: () => ipcRenderer.invoke('get-idle-state'),
|
||||
onIdleStateChanged: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, state: 'active' | 'idle') => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
|
||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||
};
|
||||
|
||||
@@ -231,6 +231,7 @@ export async function createWindow(): Promise<void> {
|
||||
video: firstSource,
|
||||
...(request.audioRequested ? { audio: 'loopback' } : {})
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user