IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

useRebootRecovery.ts

Open raw
"use client";

import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { reloadBrowserWindow } from "@/lib/desktop/browser-reload";
import {
  POWER_ACTION_STATE_CHANGED_EVENT,
  clearPersistedPowerActionState,
  readPersistedPowerActionState,
  writePersistedPowerActionCompletion,
  type PersistedPowerActionState,
} from "@/lib/desktop/reboot-state";
import { queryKeys } from "@/lib/shared/query-keys";

type PowerActionRecoveryPhase = "starting" | "waiting" | "reconnecting";

const RECOVERY_POLL_MS = 2_000;
const STARTING_PHASE_MS = 4_000;
const MIN_RESTART_TRANSITION_MS = 12_000;

export function useRebootRecovery() {
  const queryClient = useQueryClient();
  const [powerActionState, setPowerActionState] = useState<PersistedPowerActionState | null>(
    null,
  );
  const [phase, setPhase] = useState<PowerActionRecoveryPhase>("starting");
  const [isHydrated, setIsHydrated] = useState(false);

  useEffect(() => {
    if (typeof window === "undefined") return;

    const syncPowerActionState = () => {
      setPowerActionState(readPersistedPowerActionState(window.localStorage));
    };

    syncPowerActionState();
    setIsHydrated(true);

    const onStorage = () => {
      syncPowerActionState();
    };

    window.addEventListener("storage", onStorage);
    window.addEventListener(POWER_ACTION_STATE_CHANGED_EVENT, onStorage);

    return () => {
      window.removeEventListener("storage", onStorage);
      window.removeEventListener(POWER_ACTION_STATE_CHANGED_EVENT, onStorage);
    };
  }, []);

  useEffect(() => {
    if (!isHydrated || !powerActionState || typeof window === "undefined") return;

    let cancelled = false;
    let timer: ReturnType<typeof setTimeout> | null = null;
    let hasObservedUnavailable = false;

    const scheduleNextCheck = () => {
      if (cancelled) return;
      timer = setTimeout(() => {
        void checkRecovery();
      }, RECOVERY_POLL_MS);
    };

    const clearState = async (invalidateQueries: boolean, markCompleted = false) => {
      if (markCompleted && powerActionState.action === "update") {
        writePersistedPowerActionCompletion(window.localStorage, {
          action: "update",
          completedAt: new Date().toISOString(),
        });
      }

      clearPersistedPowerActionState(window.localStorage);

      if (invalidateQueries) {
        await Promise.all([
          queryClient.invalidateQueries({
            queryKey: queryKeys.currentUser,
          }),
          queryClient.invalidateQueries({
            queryKey: queryKeys.systemMetrics,
          }),
          queryClient.invalidateQueries({
            queryKey: queryKeys.installedApps,
          }),
          queryClient.invalidateQueries({
            queryKey: queryKeys.powerSchedule,
          }),
          queryClient.invalidateQueries({
            queryKey: queryKeys.systemBackups,
          }),
          queryClient.invalidateQueries({
            queryKey: queryKeys.systemUpdates,
          }),
        ]);
      }

      if (!cancelled) {
        setPowerActionState(null);
      }

      if (!cancelled && markCompleted && powerActionState.action === "update") {
        reloadBrowserWindow();
      }
    };

    const checkRecovery = async () => {
      if (cancelled) return;

      const startedAtMs = Date.parse(powerActionState.startedAt);
      const elapsedMs = Number.isFinite(startedAtMs)
        ? Date.now() - startedAtMs
        : STARTING_PHASE_MS;

      setPhase(elapsedMs < STARTING_PHASE_MS ? "starting" : "waiting");

      try {
        const healthResponse = await fetch("/api/health", {
          cache: "no-store",
        });

        if (!healthResponse.ok) {
          hasObservedUnavailable = true;
          scheduleNextCheck();
          return;
        }

        if (cancelled) return;

        const canAttemptRecovery =
          hasObservedUnavailable || elapsedMs >= MIN_RESTART_TRANSITION_MS;

        if (!canAttemptRecovery) {
          scheduleNextCheck();
          return;
        }

        setPhase("reconnecting");

        const authResponse = await fetch("/api/auth/me", {
          cache: "no-store",
        });

        if (!authResponse.ok) {
          if (authResponse.status === 401) {
            // After a restore, the DB is replaced with a backup copy. The user
            // may be locked out because the backup's password hash doesn't match
            // their current password. Set a one-shot flag so the login page can
            // show a helpful banner explaining why their credentials may not work.
            if (powerActionState.action === "restore") {
              try {
                window.localStorage.setItem("homeio:restore-banner", "1");
              } catch {
                // Best-effort only.
              }
            }
            await clearState(false);
            return;
          }

          scheduleNextCheck();
          return;
        }

        await clearState(true, true);
      } catch {
        hasObservedUnavailable = true;
        scheduleNextCheck();
      }
    };

    void checkRecovery();

    return () => {
      cancelled = true;
      if (timer) {
        clearTimeout(timer);
      }
    };
  }, [isHydrated, powerActionState, queryClient]);

  return {
    isHydrated,
    isActive: powerActionState !== null,
    action: powerActionState?.action ?? null,
    phase,
    startedAt: powerActionState?.startedAt ?? null,
  };
}
bytes
5,480
lines
191
Type
ts
Latest commit
16 October 2026