diff --git a/changelog.d/tsk-br5bqw-hw-panels-live-refresh.md b/changelog.d/tsk-br5bqw-hw-panels-live-refresh.md new file mode 100644 index 000000000..05263b660 --- /dev/null +++ b/changelog.d/tsk-br5bqw-hw-panels-live-refresh.md @@ -0,0 +1,2 @@ +### Changed +- Wi-Fi and Bluetooth settings panels now auto-refresh every 10 seconds while open, showing live device/AP changes without reopening Settings. \ No newline at end of file diff --git a/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx b/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx index c187fe4ae..bddc7b575 100644 --- a/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, act } from "@testing-library/react"; import { BluetoothPanel } from "./BluetoothPanel"; function json(obj: unknown, status = 200) { @@ -61,4 +61,38 @@ describe("BluetoothPanel", () => { render(); expect(await screen.findByRole("alert")).toBeInTheDocument(); }); + + it("refreshes devices every 10s and clears interval on unmount", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + fetchSpy + .mockResolvedValueOnce( + json({ + available: true, + adapters: [{ path: "/org/bluez/hci0", address: "AA:BB:CC:DD:EE:FF", powered: true }], + devices: [{ path: "/org/bluez/hci0/dev_11_22_33_44_55_66", address: "11:22:33:44:55:66", name: "FirstDevice", paired: true, connected: true, trusted: true }], + }) + ) + .mockResolvedValueOnce( + json({ + available: true, + adapters: [{ path: "/org/bluez/hci0", address: "AA:BB:CC:DD:EE:FF", powered: true }], + devices: [{ path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", address: "AA:BB:CC:DD:EE:FF", name: "SecondDevice", paired: true, connected: true, trusted: true }], + }) + ); + const { unmount } = render(); + await waitFor(() => expect(screen.getByText("FirstDevice")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + await waitFor(() => expect(screen.getByText("SecondDevice")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + unmount(); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }, 15000); }); diff --git a/desktop/src/apps/SettingsApp/BluetoothPanel.tsx b/desktop/src/apps/SettingsApp/BluetoothPanel.tsx index 24f992ba5..fec553cbe 100644 --- a/desktop/src/apps/SettingsApp/BluetoothPanel.tsx +++ b/desktop/src/apps/SettingsApp/BluetoothPanel.tsx @@ -7,10 +7,9 @@ export function BluetoothPanel() { const [devices, setDevices] = useState<{ path: string; address: string; name: string | null; paired: boolean; connected: boolean; trusted: boolean }[]>([]); const [error, setError] = useState(null); const mounted = useRef(true); - useEffect(() => () => { mounted.current = false; }, []); useEffect(() => { - (async () => { + const load = async () => { try { const res = await fetch("/api/hardware/bluetooth", { credentials: "include" }); const body = await res.json().catch(() => null); @@ -26,10 +25,17 @@ export function BluetoothPanel() { if (Array.isArray(body.devices)) { setDevices(body.devices); } + setError(null); } catch { if (mounted.current) setError("Could not reach taOS."); } - })(); + }; + load(); + const id = setInterval(load, 10_000); + return () => { + mounted.current = false; + clearInterval(id); + }; }, []); return ( diff --git a/desktop/src/apps/SettingsApp/WifiPanel.test.tsx b/desktop/src/apps/SettingsApp/WifiPanel.test.tsx index 7613fab29..9c910f0ac 100644 --- a/desktop/src/apps/SettingsApp/WifiPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/WifiPanel.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, act } from "@testing-library/react"; import { WifiPanel } from "./WifiPanel"; function json(obj: unknown, status = 200) { @@ -56,4 +56,40 @@ describe("WifiPanel", () => { expect(await screen.findByRole("alert")).toHaveTextContent("Could not load Wi-Fi state."); expect(screen.queryByRole("list", { name: "Wi-Fi networks" })).not.toBeInTheDocument(); }); + +it("refreshes access points every 10s and clears interval on unmount", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + fetchSpy + .mockResolvedValueOnce( + json({ + available: true, + wifi_enabled: true, + devices: [], + access_points: [{ ssid: "FirstAP", strength: 50, frequency: 2412, secured: true, active: false }], + }) + ) + .mockResolvedValueOnce( + json({ + available: true, + wifi_enabled: true, + devices: [], + access_points: [{ ssid: "SecondAP", strength: 75, frequency: 2437, secured: true, active: true }], + }) + ); + const { unmount } = render(); + await waitFor(() => expect(screen.getByText("FirstAP")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + await waitFor(() => expect(screen.getByText("SecondAP")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + unmount(); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }, 15000); }); \ No newline at end of file diff --git a/desktop/src/apps/SettingsApp/WifiPanel.tsx b/desktop/src/apps/SettingsApp/WifiPanel.tsx index 4175057e7..9fc79a8f0 100644 --- a/desktop/src/apps/SettingsApp/WifiPanel.tsx +++ b/desktop/src/apps/SettingsApp/WifiPanel.tsx @@ -8,10 +8,9 @@ export function WifiPanel() { const [accessPoints, setAccessPoints] = useState<{ ssid: string; strength: number; frequency: number; secured: boolean; active: boolean }[]>([]); const [error, setError] = useState(null); const mounted = useRef(true); - useEffect(() => () => { mounted.current = false; }, []); useEffect(() => { - (async () => { + const load = async () => { try { const res = await fetch("/api/hardware/wifi", { credentials: "include" }); const body = await res.json().catch(() => null); @@ -25,10 +24,17 @@ export function WifiPanel() { if (body.available && body.wifi_enabled && Array.isArray(body.access_points)) { setAccessPoints(body.access_points); } + setError(null); } catch { if (mounted.current) setError("Could not reach taOS."); } - })(); + }; + load(); + const id = setInterval(load, 10_000); + return () => { + mounted.current = false; + clearInterval(id); + }; }, []); return (