import Axios, { CancelTokenSource } from 'axios' import { clearTimeout, setTimeout } from 'timers' import { LOCK_TIMEOUT } from './consts' import { LockNotHeldError } from './errors' /** * The lock data given back to the requester. */ export interface Lock { id: number } /** * The initiators who can request a lock. */ export enum Initiator { /** * The user, requesting a different image be displayed */ user, /** * A watcher function which gets triggered when the display config changes */ displayChangeWatcher, /** * The heartbeat function which is triggered at a normal interval to make * sure everything is working as it should. */ heartbeatFunction } /** * Locking mechanism to prevent race conditions when updating background * wallpaper. */ export class UpdateLock { // Current active lock on the update pipeline, if any private static activeLock?: UpdateLock // Initiator of the update private initiator: Initiator // Lock invalidation timeout instance (for cancelling the timeout function) private lockTimeout: NodeJS.Timeout // Key-mapped download cancel tokens for a lock private downloadCancelTokens: Set<CancelTokenSource> private constructor(initiator: Initiator) { this.initiator = initiator this.downloadCancelTokens = new Set() this.lockTimeout = setTimeout(() => { this.invalidate() }, LOCK_TIMEOUT) } /** * Should a new lock be granted to an initiator, given the current lock. * * @param currentLock - The current lock * @param newInitiator - Initiator requesting a new lock * @returns Whether a new lock should be granted */ // eslint-disable-next-line consistent-return private static shouldGrantNewLock(currentLock: UpdateLock, newInitiator: Initiator): boolean { // eslint-disable-next-line default-case switch (newInitiator) { case Initiator.user: return true case Initiator.heartbeatFunction: return false case Initiator.displayChangeWatcher: return true } } /** * Attempt to acquire a lock on the update pipeline. * * If there is already an update with higher precedence, `undefined` will be * returned, meaning a lock could not be acquired. * * If this acquisition takes precedent over one in progress, the one in * progress will be invalidated and a new lock will be granted. * * @param initiator - The initiator of the update * @returns A lock if it can be acquired, else `undefined` */ public static acquire(initiator: Initiator): UpdateLock | undefined { if (UpdateLock.activeLock !== undefined) { if (!UpdateLock.shouldGrantNewLock(UpdateLock.activeLock, initiator)) { return undefined } UpdateLock.activeLock.invalidate() } UpdateLock.activeLock = new this(initiator) return UpdateLock.activeLock } /** * Invalidate the lock. */ public invalidate(): void { // Cancel any download tokens and release this.downloadCancelTokens.forEach(token => token.cancel()) this.release() } /** * Generate a new Axios download cancel token. * * @throws {LockNotHeldError} if lock is no longer held * @returns New cancel token */ public generateCancelToken(): CancelTokenSource { if (!this.isStillHeld()) { throw new LockNotHeldError() } const token = Axios.CancelToken.source() this.downloadCancelTokens.add(token) return token } /** * Remove reference to a token. * * @param token - Token to remove */ public destroyCancelToken(token: CancelTokenSource): void { this.downloadCancelTokens.delete(token) } /** * Check if the update pipeline lock is still held. * * @returns Whether the lock is still held */ public isStillHeld(): boolean { return this === UpdateLock.activeLock } /** * Release lock on the download pipeline. */ public release(): void { clearTimeout(this.lockTimeout) if (this === UpdateLock.activeLock) { UpdateLock.activeLock = undefined } } }