import { useCallback, useEffect, useMemo, useReducer } from "react"; import { activateMachine, deactivateMachine, listActivatedMachines, getActivationStatus, type ActivationStatus, type LicenseError, type LicenseResult, type MachineInfo, } from "../services/licenseService"; import { useLicenseContext } from "../contexts/LicenseContext"; // Machine activation state of the License card (issue #270). // // The license server allows 5 requests per minute and per IP, shared by // activate, verify and deactivate. The machine-limit path (activate → 409 → // list via verify → deactivate → activate) therefore has to fit in 4 calls: // a successful deactivation removes the machine locally instead of re-running // verify, and nothing loads the list from an effect (StrictMode would double // it). List loads come only from explicit handlers. export interface LicenseMachinesState { activation: ActivationStatus | null; machines: MachineInfo[]; listOpen: boolean; listLoading: boolean; listLoaded: boolean; /** Error of the list itself (verify) or of a deactivation. */ listError: LicenseError | null; activating: boolean; /** Error of the last activation. Survives the list load it may trigger. */ activationError: LicenseError | null; /** Machine being deactivated; every Deactivate button is disabled meanwhile. */ deactivatingId: string | null; } export type LicenseMachinesAction = | { type: "ACTIVATION_STATUS"; activation: ActivationStatus } | { type: "TOGGLE_LIST" } | { type: "LIST_LOAD_START" } | { type: "LIST_LOAD_DONE"; machines: MachineInfo[] } | { type: "LIST_LOAD_ERROR"; error: LicenseError } | { type: "ACTIVATE_START" } | { type: "ACTIVATE_DONE" } | { type: "ACTIVATE_ERROR"; error: LicenseError } | { type: "DEACTIVATE_START"; machineId: string } | { type: "DEACTIVATE_DONE"; machineId: string } | { type: "DEACTIVATE_ERROR"; error: LicenseError }; export const initialLicenseMachinesState: LicenseMachinesState = { activation: null, machines: [], listOpen: false, listLoading: false, listLoaded: false, listError: null, activating: false, activationError: null, deactivatingId: null, }; export function licenseMachinesReducer( state: LicenseMachinesState, action: LicenseMachinesAction, ): LicenseMachinesState { switch (action.type) { case "ACTIVATION_STATUS": return { ...state, activation: action.activation }; case "TOGGLE_LIST": return { ...state, listOpen: !state.listOpen }; // The list actions never touch activationError: the machine-limit message // must stay visible while the list it opened loads (D2). case "LIST_LOAD_START": return { ...state, listLoading: true, listError: null }; case "LIST_LOAD_DONE": return { ...state, listLoading: false, listLoaded: true, listError: null, machines: action.machines, }; case "LIST_LOAD_ERROR": // listLoaded stays false so that reopening the list retries. return { ...state, listLoading: false, listLoaded: false, listError: action.error, machines: [], }; case "ACTIVATE_START": return { ...state, activating: true, activationError: null }; case "ACTIVATE_DONE": // The loaded list no longer reflects the server: close it and mark it // stale rather than spending another verify call. return { ...state, activating: false, listOpen: false, listLoaded: false, machines: [] }; case "ACTIVATE_ERROR": return { ...state, activating: false, activationError: action.error, listOpen: action.error.code === "machine_limit_reached" ? true : state.listOpen, }; case "DEACTIVATE_START": return { ...state, deactivatingId: action.machineId, listError: null }; case "DEACTIVATE_DONE": return { ...state, deactivatingId: null, machines: state.machines.filter((m) => m.machine_id !== action.machineId), // A slot is now free: the machine-limit message no longer holds. activationError: state.activationError?.code === "machine_limit_reached" ? null : state.activationError, }; case "DEACTIVATE_ERROR": return { ...state, deactivatingId: null, listError: action.error }; } } /** The "no machine activated" line: only for a loaded, empty, error-free list. */ export function selectShowEmptyState(state: LicenseMachinesState): boolean { return ( state.listLoaded && !state.listLoading && state.machines.length === 0 && state.listError === null ); } /** * Deactivate buttons are live only when no deactivation and no list reload is running. A * reload (e.g. the one a 409 triggers over an already loaded list) returns the server's * list as of its request: a machine deactivated meanwhile would reappear in it. */ export function selectCanDeactivate(state: LicenseMachinesState): boolean { return state.deactivatingId === null && !state.listLoading; } /** Whether opening the list (from `state`, before the toggle) should load it. */ export function shouldLoadOnOpen(state: LicenseMachinesState): boolean { return !state.listOpen && !state.listLoading && (!state.listLoaded || state.listError !== null); } export interface LicenseMachinesDeps { activateMachine: () => Promise>; deactivateMachine: (machineId: string) => Promise>; listActivatedMachines: () => Promise>; getActivationStatus: () => Promise; refresh: () => Promise; dispatch: (action: LicenseMachinesAction) => void; } /** Local read (no network). Best effort: a failure keeps the last known status. */ export async function loadActivationStatus(deps: LicenseMachinesDeps): Promise { try { const activation = await deps.getActivationStatus(); deps.dispatch({ type: "ACTIVATION_STATUS", activation }); } catch { // The card keeps showing the previous activation status. } } export async function loadMachines(deps: LicenseMachinesDeps): Promise { deps.dispatch({ type: "LIST_LOAD_START" }); const result = await deps.listActivatedMachines(); deps.dispatch( result.ok ? { type: "LIST_LOAD_DONE", machines: result.value } : { type: "LIST_LOAD_ERROR", error: result.error }, ); } export async function performActivate(deps: LicenseMachinesDeps): Promise { deps.dispatch({ type: "ACTIVATE_START" }); const result = await deps.activateMachine(); if (!result.ok) { deps.dispatch({ type: "ACTIVATE_ERROR", error: result.error }); if (result.error.code === "machine_limit_reached") { await loadMachines(deps); } return; } deps.dispatch({ type: "ACTIVATE_DONE" }); await loadActivationStatus(deps); // The edition shown by the app follows without a restart. await deps.refresh(); } export async function performDeactivate(deps: LicenseMachinesDeps, machineId: string): Promise { deps.dispatch({ type: "DEACTIVATE_START", machineId }); const result = await deps.deactivateMachine(machineId); if (!result.ok) { deps.dispatch({ type: "DEACTIVATE_ERROR", error: result.error }); return; } // Removed locally, no verify call: see the rate budget note at the top. deps.dispatch({ type: "DEACTIVATE_DONE", machineId }); await loadActivationStatus(deps); await deps.refresh(); } /** * Machine list and activation of the License card. `enabled` is false in the * free edition, where the card shows no machine section. */ export function useLicenseMachines(enabled: boolean) { const { refresh } = useLicenseContext(); const [state, dispatch] = useReducer(licenseMachinesReducer, initialLicenseMachinesState); const deps = useMemo( () => ({ activateMachine, deactivateMachine, listActivatedMachines, getActivationStatus, refresh, dispatch, }), [refresh], ); // Local activation status only (get_activation_status reads the token on // disk): safe to run twice under StrictMode. useEffect(() => { if (enabled) void loadActivationStatus(deps); }, [enabled, deps]); const activate = useCallback(() => performActivate(deps), [deps]); const deactivate = useCallback((machineId: string) => performDeactivate(deps, machineId), [deps]); const toggleList = useCallback(() => { const load = shouldLoadOnOpen(state); dispatch({ type: "TOGGLE_LIST" }); if (load) void loadMachines(deps); }, [state, deps]); return { state, activate, deactivate, toggleList, showEmptyState: selectShowEmptyState(state), canDeactivate: selectCanDeactivate(state), }; }