From 27283ddf3cdfebc2ba9e930bd691d88269c48388 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Wed, 15 Apr 2026 21:01:43 +0200 Subject: [PATCH 1/5] feat: add state-machine for appLockflow [WPB-23186] --- .../page/AppLock/appLockStateMachine.ts | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 apps/webapp/src/script/page/AppLock/appLockStateMachine.ts diff --git a/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts new file mode 100644 index 00000000000..26caaedeea4 --- /dev/null +++ b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts @@ -0,0 +1,275 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {assign, createMachine, fromCallback} from 'xstate'; + +import {WallClock} from '../../clock/wallClock'; + +const DEFAULT_INACTIVITY_TIMEOUT_MS = 60_000; // 1 minute + +/** + * DOM events that count as user activity and reset the inactivity debounce timer. + */ +const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'mousedown', 'touchstart', 'wheel', 'scroll'] as const; + +export const appLockEventType = Object.freeze({ + /** User enables "Lock with passcode" in preferences. */ + enable: 'ENABLE', + /** User disables "Lock with passcode" in preferences. */ + disable: 'DISABLE', + /** Team policy begins enforcing app lock. */ + lockEnforced: 'LOCK_ENFORCED', + /** User successfully set or changed their passcode. */ + passcodeConfirmed: 'PASSCODE_CONFIRMED', + /** Inactivity debounce timer fired — no user activity for inactivityTimeoutMs. */ + inactivityTimeout: 'INACTIVITY_TIMEOUT', + /** User entered the correct passcode on the lock screen. */ + unlockSuccess: 'UNLOCK_SUCCESS', + /** User clicked "Forgot passcode" on the lock screen. */ + forgotPasscode: 'FORGOT_PASSCODE', + /** User navigates back to locked screen from forgotPasscode. */ + backToLocked: 'BACK_TO_LOCKED', + /** User wants to change their passcode from preferences. */ + changePasscode: 'CHANGE_PASSCODE', + /** User chooses "Logout + delete all data" from forgotPasscode screen. */ + logoutWithWipe: 'LOGOUT_WITH_WIPE', + /** User chooses "Logout without deleting data" from forgotPasscode screen. */ + logoutWithoutWipe: 'LOGOUT_WITHOUT_WIPE', + /** User confirms the data wipe from wipeConfirm screen. */ + confirmWipe: 'CONFIRM_WIPE', + /** User cancels the data wipe from wipeConfirm screen. */ + cancelWipe: 'CANCEL_WIPE', +} as const); + +export type AppLockMachineEvent = { + [K in keyof typeof appLockEventType]: {type: (typeof appLockEventType)[K]}; +}[keyof typeof appLockEventType]; + +export const appLockStateName = Object.freeze({ + /** + * App lock is not active. No passcode is set and no policy enforces it. + * Modal shown: none. + */ + unprotected: 'unprotected', + /** + * User is creating or changing their passcode. + * context.isChangingPasscode distinguishes a change from first-time setup. + * Modal shown: passcode setup form. + */ + setup: 'setup', + /** + * App is unlocked. The inactivity callback actor is running and debouncing + * user activity. After inactivityTimeoutMs of silence it fires INACTIVITY_TIMEOUT. + * Modal shown: none (app is fully accessible). + */ + unlocked: 'unlocked', + /** + * App is locked. Waiting for the correct passcode. + * Modal shown: passcode entry. + */ + locked: 'locked', + /** + * User tapped "Forgot passcode". Showing logout options. + * Modal shown: forgot-passcode / logout options. + */ + forgotPasscode: 'forgotPasscode', + /** + * User chose "Logout + delete all data". Waiting for final confirmation. + * Modal shown: wipe confirmation. + */ + wipeConfirm: 'wipeConfirm', +} as const); + +export type AppLockStateName = (typeof appLockStateName)[keyof typeof appLockStateName]; + +export type AppLockMachineInput = { + readonly wallClock: WallClock; + readonly inactivityTimeoutMs?: number; + readonly isEnabled: boolean; + readonly isEnforced: boolean; + readonly hasPasscode: boolean; + readonly requiresPasscodeCreation: boolean; +}; + +type AppLockMachineContext = { + readonly wallClock: WallClock; + readonly inactivityTimeoutMs: number; + readonly isEnforced: boolean; + readonly isChangingPasscode: boolean; +}; + +type InactivityActorInput = { + readonly wallClock: WallClock; + readonly timeoutMs: number; +}; + +type InactivityActorEvent = {type: typeof appLockEventType.inactivityTimeout}; + +const inactivityCallbackActor = fromCallback(({sendBack, input}) => { + const {wallClock, timeoutMs} = input; + let debounceTimeoutId: ReturnType | null = null; + + const scheduleTimeout = () => { + if (debounceTimeoutId !== null) { + wallClock.clearTimeout(debounceTimeoutId); + } + debounceTimeoutId = wallClock.setTimeout(() => { + sendBack({type: appLockEventType.inactivityTimeout}); + }, timeoutMs); + }; + + ACTIVITY_EVENTS.forEach(event => { + globalThis.addEventListener(event, scheduleTimeout, {passive: true}); + }); + + scheduleTimeout(); + + return () => { + if (debounceTimeoutId !== null) { + wallClock.clearTimeout(debounceTimeoutId); + } + ACTIVITY_EVENTS.forEach(event => { + globalThis.removeEventListener(event, scheduleTimeout); + }); + }; +}); + +function resolveInitialState(input: AppLockMachineInput): AppLockStateName { + if (input.requiresPasscodeCreation) { + return appLockStateName.setup; + } + if (!input.isEnabled && !input.isEnforced) { + return appLockStateName.unprotected; + } + if (input.hasPasscode) { + return appLockStateName.unlocked; + } + return appLockStateName.setup; +} + +export function createAppLockStateMachine(input: AppLockMachineInput) { + return createMachine({ + id: 'appLock', + + types: { + context: {} as AppLockMachineContext, + events: {} as AppLockMachineEvent, + }, + + context: { + wallClock: input.wallClock, + inactivityTimeoutMs: input.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS, + isEnforced: input.isEnforced, + isChangingPasscode: false, + }, + + initial: resolveInitialState(input), + + states: { + [appLockStateName.unprotected]: { + on: { + [appLockEventType.enable]: { + target: appLockStateName.setup, + }, + [appLockEventType.lockEnforced]: { + target: appLockStateName.setup, + actions: assign({isEnforced: true}), + }, + }, + }, + + [appLockStateName.setup]: { + on: { + [appLockEventType.passcodeConfirmed]: { + target: appLockStateName.unlocked, + actions: assign({isChangingPasscode: false}), + }, + [appLockEventType.disable]: { + target: appLockStateName.unprotected, + guard: ({context}) => !context.isEnforced && context.isChangingPasscode, + actions: assign({isChangingPasscode: false}), + }, + }, + }, + + [appLockStateName.unlocked]: { + invoke: { + id: 'inactivityTimer', + src: inactivityCallbackActor, + input: ({context}: {context: AppLockMachineContext}) => ({ + wallClock: context.wallClock, + timeoutMs: context.inactivityTimeoutMs, + }), + }, + on: { + [appLockEventType.inactivityTimeout]: { + target: appLockStateName.locked, + }, + [appLockEventType.changePasscode]: { + target: appLockStateName.setup, + actions: assign({isChangingPasscode: true}), + }, + [appLockEventType.disable]: { + target: appLockStateName.unprotected, + guard: ({context}) => !context.isEnforced, + }, + [appLockEventType.lockEnforced]: { + actions: assign({isEnforced: true}), + }, + }, + }, + + [appLockStateName.locked]: { + on: { + [appLockEventType.unlockSuccess]: { + target: appLockStateName.unlocked, + }, + [appLockEventType.forgotPasscode]: { + target: appLockStateName.forgotPasscode, + }, + }, + }, + + [appLockStateName.forgotPasscode]: { + on: { + [appLockEventType.backToLocked]: { + target: appLockStateName.locked, + }, + [appLockEventType.logoutWithWipe]: { + target: appLockStateName.wipeConfirm, + }, + [appLockEventType.logoutWithoutWipe]: { + target: appLockStateName.unprotected, + }, + }, + }, + + [appLockStateName.wipeConfirm]: { + on: { + [appLockEventType.confirmWipe]: { + target: appLockStateName.unprotected, + }, + [appLockEventType.cancelWipe]: { + target: appLockStateName.forgotPasscode, + }, + }, + }, + }, + }); +} From d29490e194a5ee9454f57e3eaa9324232aec4f64 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 16 Apr 2026 17:20:30 +0200 Subject: [PATCH 2/5] fix: remove events handling from statemachine --- .../page/AppLock/appLockStateMachine.ts | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts index 26caaedeea4..b8577841785 100644 --- a/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts +++ b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts @@ -17,17 +17,12 @@ * */ -import {assign, createMachine, fromCallback} from 'xstate'; +import {assign, createMachine, fromCallback, sendTo} from 'xstate'; import {WallClock} from '../../clock/wallClock'; const DEFAULT_INACTIVITY_TIMEOUT_MS = 60_000; // 1 minute -/** - * DOM events that count as user activity and reset the inactivity debounce timer. - */ -const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'mousedown', 'touchstart', 'wheel', 'scroll'] as const; - export const appLockEventType = Object.freeze({ /** User enables "Lock with passcode" in preferences. */ enable: 'ENABLE', @@ -37,6 +32,8 @@ export const appLockEventType = Object.freeze({ lockEnforced: 'LOCK_ENFORCED', /** User successfully set or changed their passcode. */ passcodeConfirmed: 'PASSCODE_CONFIRMED', + /** User activity detected (injected from outside the machine). */ + userActivity: 'USER_ACTIVITY', /** Inactivity debounce timer fired — no user activity for inactivityTimeoutMs. */ inactivityTimeout: 'INACTIVITY_TIMEOUT', /** User entered the correct passcode on the lock screen. */ @@ -119,36 +116,50 @@ type InactivityActorInput = { readonly timeoutMs: number; }; -type InactivityActorEvent = {type: typeof appLockEventType.inactivityTimeout}; - -const inactivityCallbackActor = fromCallback(({sendBack, input}) => { - const {wallClock, timeoutMs} = input; - let debounceTimeoutId: ReturnType | null = null; +/** + * Events that the inactivity timer actor can receive. + */ +type InactivityActorEvent = + | {type: 'RESET_TIMER'} // Sent when user activity is detected + | {type: typeof appLockEventType.inactivityTimeout}; // Not used as input, only sent to parent - const scheduleTimeout = () => { - if (debounceTimeoutId !== null) { - wallClock.clearTimeout(debounceTimeoutId); - } - debounceTimeoutId = wallClock.setTimeout(() => { - sendBack({type: appLockEventType.inactivityTimeout}); - }, timeoutMs); - }; +/** + * This callback actor manages a debounced inactivity timer. + * It starts a timer when created and resets it whenever it receives a RESET_TIMER event. + * After timeout milliseconds of no RESET_TIMER events, it sends INACTIVITY_TIMEOUT to the parent machine. + */ +const inactivityCallbackActor = fromCallback( + ({sendBack, input, receive}) => { + const {wallClock, timeoutMs} = input; + let debounceTimeoutId: ReturnType | null = null; - ACTIVITY_EVENTS.forEach(event => { - globalThis.addEventListener(event, scheduleTimeout, {passive: true}); - }); + const scheduleTimeout = () => { + if (debounceTimeoutId !== null) { + wallClock.clearTimeout(debounceTimeoutId); + } + debounceTimeoutId = wallClock.setTimeout(() => { + sendBack({type: appLockEventType.inactivityTimeout}); + }, timeoutMs); + }; - scheduleTimeout(); + // Start the initial timer + scheduleTimeout(); - return () => { - if (debounceTimeoutId !== null) { - wallClock.clearTimeout(debounceTimeoutId); - } - ACTIVITY_EVENTS.forEach(event => { - globalThis.removeEventListener(event, scheduleTimeout); + // Listen for RESET_TIMER events from the parent machine + receive(event => { + if (event.type === 'RESET_TIMER') { + scheduleTimeout(); + } }); - }; -}); + + // Cleanup on actor stop + return () => { + if (debounceTimeoutId !== null) { + wallClock.clearTimeout(debounceTimeoutId); + } + }; + }, +); function resolveInitialState(input: AppLockMachineInput): AppLockStateName { if (input.requiresPasscodeCreation) { @@ -218,6 +229,9 @@ export function createAppLockStateMachine(input: AppLockMachineInput) { }), }, on: { + [appLockEventType.userActivity]: { + actions: sendTo('inactivityTimer', {type: 'RESET_TIMER'}), + }, [appLockEventType.inactivityTimeout]: { target: appLockStateName.locked, }, From 7151d1af95ec55e2cb82ceb715189b1cc0e67a19 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 16 Apr 2026 17:22:14 +0200 Subject: [PATCH 3/5] feat: add tests for appLock stateMachine --- .../page/AppLock/appLockStateMachine.test.ts | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 apps/webapp/src/script/page/AppLock/appLockStateMachine.test.ts diff --git a/apps/webapp/src/script/page/AppLock/appLockStateMachine.test.ts b/apps/webapp/src/script/page/AppLock/appLockStateMachine.test.ts new file mode 100644 index 00000000000..f59c6603d6e --- /dev/null +++ b/apps/webapp/src/script/page/AppLock/appLockStateMachine.test.ts @@ -0,0 +1,412 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {createActor, waitFor} from 'xstate'; + +import {createDeterministicWallClock} from '../../clock/deterministicWallClock'; +import {appLockEventType, appLockStateName, createAppLockStateMachine} from './appLockStateMachine'; + +describe('appLockStateMachine', () => { + describe('initial state resolution', () => { + it('starts in unprotected when disabled and no passcode', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: false, + isEnforced: false, + hasPasscode: false, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unprotected); + + actor.stop(); + }); + + it('starts in setup when passcode creation is required', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: false, + requiresPasscodeCreation: true, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.setup); + + actor.stop(); + }); + + it('starts in unlocked when enabled with passcode', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + actor.stop(); + }); + + it('starts in setup when enabled but no passcode', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: false, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.setup); + + actor.stop(); + }); + }); + + describe('inactivity tracking with user activity', () => { + it('locks after inactivity timeout when no user activity', async () => { + const wallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0}); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + inactivityTimeoutMs: 60_000, // 1 minute + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + // Advance time by 59 seconds - should still be unlocked + wallClock.advanceByMilliseconds(59_000); + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + // Advance time by 1 more second to trigger timeout + wallClock.advanceByMilliseconds(1_000); + await waitFor(actor, state => state.matches(appLockStateName.locked)); + + expect(actor.getSnapshot().value).toBe(appLockStateName.locked); + + actor.stop(); + }); + + it('resets inactivity timer when user activity is detected', async () => { + const wallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0}); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + inactivityTimeoutMs: 60_000, // 1 minute + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + // Advance time by 50 seconds + wallClock.advanceByMilliseconds(50_000); + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + // Send user activity event to reset the timer + actor.send({type: appLockEventType.userActivity}); + + // Advance time by another 50 seconds (total 100s, but timer was reset at 50s) + wallClock.advanceByMilliseconds(50_000); + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + // Advance time by another 10 seconds (60s since last activity) + wallClock.advanceByMilliseconds(10_000); + await waitFor(actor, state => state.matches(appLockStateName.locked)); + + expect(actor.getSnapshot().value).toBe(appLockStateName.locked); + + actor.stop(); + }); + + it('continues to reset timer with ongoing user activity', async () => { + const wallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: 0}); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + inactivityTimeoutMs: 60_000, // 1 minute + }); + + const actor = createActor(machine); + actor.start(); + + // Send activity every 30 seconds for 3 minutes + for (let i = 0; i < 6; i++) { + wallClock.advanceByMilliseconds(30_000); + actor.send({type: appLockEventType.userActivity}); + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + } + + // Now stop activity and wait for timeout + wallClock.advanceByMilliseconds(60_000); + await waitFor(actor, state => state.matches(appLockStateName.locked)); + + expect(actor.getSnapshot().value).toBe(appLockStateName.locked); + + actor.stop(); + }); + }); + + describe('state transitions', () => { + it('transitions from unprotected to setup when enabled', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: false, + isEnforced: false, + hasPasscode: false, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unprotected); + + actor.send({type: appLockEventType.enable}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.setup); + + actor.stop(); + }); + + it('transitions from setup to unlocked when passcode is confirmed', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: false, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.setup); + + actor.send({type: appLockEventType.passcodeConfirmed}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + actor.stop(); + }); + + it('transitions from locked to unlocked when unlock is successful', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + inactivityTimeoutMs: 0, // Immediate lock for testing + }); + + const actor = createActor(machine); + actor.start(); + + // Advance time to trigger lock + wallClock.advanceByMilliseconds(1); + + expect(actor.getSnapshot().value).toBe(appLockStateName.locked); + + actor.send({type: appLockEventType.unlockSuccess}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + actor.stop(); + }); + + it('transitions from locked to forgotPasscode', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + inactivityTimeoutMs: 0, + }); + + const actor = createActor(machine); + actor.start(); + + wallClock.advanceByMilliseconds(1); + expect(actor.getSnapshot().value).toBe(appLockStateName.locked); + + actor.send({type: appLockEventType.forgotPasscode}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.forgotPasscode); + + actor.stop(); + }); + + it('transitions from forgotPasscode to wipeConfirm when logout with wipe', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + inactivityTimeoutMs: 0, + }); + + const actor = createActor(machine); + actor.start(); + + wallClock.advanceByMilliseconds(1); + actor.send({type: appLockEventType.forgotPasscode}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.forgotPasscode); + + actor.send({type: appLockEventType.logoutWithWipe}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.wipeConfirm); + + actor.stop(); + }); + }); + + describe('context management', () => { + it('sets isChangingPasscode when changing passcode from unlocked', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().context.isChangingPasscode).toBe(false); + + actor.send({type: appLockEventType.changePasscode}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.setup); + expect(actor.getSnapshot().context.isChangingPasscode).toBe(true); + + actor.stop(); + }); + + it('updates isEnforced when lock is enforced', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: false, + isEnforced: false, + hasPasscode: false, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().context.isEnforced).toBe(false); + + actor.send({type: appLockEventType.lockEnforced}); + + expect(actor.getSnapshot().context.isEnforced).toBe(true); + + actor.stop(); + }); + }); + + describe('guards', () => { + it('prevents disabling when enforced', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: true, + hasPasscode: true, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + actor.send({type: appLockEventType.disable}); + + // Should remain in unlocked because it's enforced + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + actor.stop(); + }); + + it('allows disabling when not enforced', () => { + const wallClock = createDeterministicWallClock(); + const machine = createAppLockStateMachine({ + wallClock, + isEnabled: true, + isEnforced: false, + hasPasscode: true, + requiresPasscodeCreation: false, + }); + + const actor = createActor(machine); + actor.start(); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unlocked); + + actor.send({type: appLockEventType.disable}); + + expect(actor.getSnapshot().value).toBe(appLockStateName.unprotected); + + actor.stop(); + }); + }); +}); From 1216f58b62d63648d8b26ca2cde2be448717dfe2 Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 16 Apr 2026 17:46:12 +0200 Subject: [PATCH 4/5] fix: renamed variables for clarity --- .../page/AppLock/appLockStateMachine.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts index b8577841785..c37633350fa 100644 --- a/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts +++ b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts @@ -71,8 +71,9 @@ export const appLockStateName = Object.freeze({ */ setup: 'setup', /** - * App is unlocked. The inactivity callback actor is running and debouncing - * user activity. After inactivityTimeoutMs of silence it fires INACTIVITY_TIMEOUT. + * App is unlocked. The inactivity callback actor is running and tracking + * time since last user activity. After inactivityTimeoutMs of no activity, + * it fires INACTIVITY_TIMEOUT. * Modal shown: none (app is fully accessible). */ unlocked: 'unlocked', @@ -124,38 +125,41 @@ type InactivityActorEvent = | {type: typeof appLockEventType.inactivityTimeout}; // Not used as input, only sent to parent /** - * This callback actor manages a debounced inactivity timer. + * This callback actor manages an inactivity timer. * It starts a timer when created and resets it whenever it receives a RESET_TIMER event. * After timeout milliseconds of no RESET_TIMER events, it sends INACTIVITY_TIMEOUT to the parent machine. + * + * Note: This is NOT a debounce of activity events - it's a trailing-edge inactivity detector. + * Every activity event resets the timer; we're measuring time since last activity, not rate-limiting events. */ const inactivityCallbackActor = fromCallback( ({sendBack, input, receive}) => { const {wallClock, timeoutMs} = input; - let debounceTimeoutId: ReturnType | null = null; + let inactivityTimeoutId: ReturnType | null = null; - const scheduleTimeout = () => { - if (debounceTimeoutId !== null) { - wallClock.clearTimeout(debounceTimeoutId); + const resetInactivityTimer = () => { + if (inactivityTimeoutId !== null) { + wallClock.clearTimeout(inactivityTimeoutId); } - debounceTimeoutId = wallClock.setTimeout(() => { + inactivityTimeoutId = wallClock.setTimeout(() => { sendBack({type: appLockEventType.inactivityTimeout}); }, timeoutMs); }; - // Start the initial timer - scheduleTimeout(); + // Start the initial inactivity timer + resetInactivityTimer(); // Listen for RESET_TIMER events from the parent machine receive(event => { if (event.type === 'RESET_TIMER') { - scheduleTimeout(); + resetInactivityTimer(); } }); // Cleanup on actor stop return () => { - if (debounceTimeoutId !== null) { - wallClock.clearTimeout(debounceTimeoutId); + if (inactivityTimeoutId !== null) { + wallClock.clearTimeout(inactivityTimeoutId); } }; }, From 634e4608b959be4a35afa86eac6ca6d65a69d49b Mon Sep 17 00:00:00 2001 From: Immad Abdul Jabbar Date: Thu, 16 Apr 2026 22:43:14 +0200 Subject: [PATCH 5/5] fixed lint --- apps/webapp/src/script/page/AppLock/appLockStateMachine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts index c37633350fa..86ed0fbaf3b 100644 --- a/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts +++ b/apps/webapp/src/script/page/AppLock/appLockStateMachine.ts @@ -128,7 +128,7 @@ type InactivityActorEvent = * This callback actor manages an inactivity timer. * It starts a timer when created and resets it whenever it receives a RESET_TIMER event. * After timeout milliseconds of no RESET_TIMER events, it sends INACTIVITY_TIMEOUT to the parent machine. - * + * * Note: This is NOT a debounce of activity events - it's a trailing-edge inactivity detector. * Every activity event resets the timer; we're measuring time since last activity, not rate-limiting events. */