Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/public/i18n/locales/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@
}
}
},
"firmwareUpdate": {
"title": "Firmware update available",
"description": "{{currentVersion}} is behind stable {{latestStableVersion}}. Use Meshtastic Flasher to update this hardware.",
"action": {
"label": "Open Meshtastic Flasher",
"ariaLabel": "Open Meshtastic Flasher to update this hardware",
"hint": "Opens Meshtastic Flasher"
}
},
"notifications": {
"copied": {
"label": "Copied!"
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { ConnectingOverlay } from "@components/ConnectingOverlay.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { FirmwareUpdateReminder } from "@components/FirmwareUpdateReminder.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { RegionSetupReminder } from "@components/RegionSetupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
Expand Down Expand Up @@ -43,6 +44,7 @@ export function App() {
<DialogManager />
<KeyBackupReminder />
<RegionSetupReminder />
<FirmwareUpdateReminder />
<CommandPalette />
<MapProvider>
<Outlet />
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/FirmwareUpdateNudge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FirmwareUpdateNudge } from "./FirmwareUpdateNudge.tsx";

describe("FirmwareUpdateNudge", () => {
it("uses the neutral system_update_alt affordance and names the Flasher destination", () => {
const onOpen = vi.fn();
const { container } = render(
<FirmwareUpdateNudge
currentVersion="2.7.26"
latestStableVersion="2.8.0"
onOpen={onOpen}
/>,
);

expect(screen.getByText("Firmware update available")).toBeVisible();
expect(
screen.getByText(
"2.7.26 is behind stable 2.8.0. Use Meshtastic Flasher to update this hardware.",
),
).toBeVisible();
expect(
container.querySelector('[data-icon="system_update_alt"]'),
).toBeTruthy();
expect(container.innerHTML).not.toContain("exclamation");
expect(container.innerHTML).not.toContain("red-");
expect(container.innerHTML).not.toContain("amber-");

fireEvent.click(
screen.getByRole("button", {
name: "Open Meshtastic Flasher to update this hardware",
}),
);
expect(onOpen).toHaveBeenCalledOnce();
});
});
56 changes: 56 additions & 0 deletions apps/web/src/components/FirmwareUpdateNudge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Button } from "@components/UI/Button.tsx";
import { useTranslation } from "react-i18next";

interface FirmwareUpdateNudgeProps {
currentVersion: string;
latestStableVersion: string;
onOpen: () => void;
}

const SystemUpdateAltIcon = () => (
<svg
aria-hidden="true"
data-icon="system_update_alt"
viewBox="0 0 24 24"
className="size-5 text-sky-600 dark:text-sky-400"
>
<path d="M13 3h-2v10.17l-3.59-3.58L6 11l6 6 6-6-1.41-1.41L13 13.17V3zM5 19v2h14v-2H5z" />
</svg>
);

export const FirmwareUpdateNudge = ({
currentVersion,
latestStableVersion,
onOpen,
}: FirmwareUpdateNudgeProps) => {
const { t } = useTranslation("ui");

return (
<div
aria-live="polite"
className="flex flex-col gap-3 rounded-md border border-sky-200 bg-sky-50 p-4 text-slate-900 shadow-lg dark:border-sky-900 dark:bg-slate-800 dark:text-slate-100"
>
<div className="flex gap-3">
<SystemUpdateAltIcon />
<div className="space-y-1">
<p className="font-semibold">{t("firmwareUpdate.title")}</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t("firmwareUpdate.description", {
currentVersion,
latestStableVersion,
})}
</p>
</div>
</div>
<Button
className="self-start"
variant="outline"
aria-label={t("firmwareUpdate.action.ariaLabel")}
title={t("firmwareUpdate.action.hint")}
onClick={onOpen}
>
{t("firmwareUpdate.action.label")}
</Button>
</div>
);
};
69 changes: 69 additions & 0 deletions apps/web/src/components/FirmwareUpdateReminder.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FirmwareUpdateReminder } from "./FirmwareUpdateReminder.tsx";

const { mockUseDevice } = vi.hoisted(() => ({ mockUseDevice: vi.fn() }));
const mockFetch = vi.fn();

vi.mock("@core/stores", () => ({ useDevice: mockUseDevice }));

describe("FirmwareUpdateReminder", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
releases: {
stable: [
{
id: "v2.8.0",
zip_url: "https://example.test/firmware-2.8.0.json",
},
],
},
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ targets: [{ board: "tbeam-s3-core" }] }),
});
mockUseDevice.mockReturnValue({
status: 7,
myNodeNum: 4660,
hardware: { pioEnv: "tbeam-s3-core" },
metadata: new Map([
[
0,
{
firmwareVersion: "2.7.26.54e0d8d",
},
],
]),
});
});

it("shows a dedicated nudge only while the actual device lifecycle is configured", async () => {
const { rerender } = render(<FirmwareUpdateReminder />);

await waitFor(() => {
expect(screen.getByText("Firmware update available")).toBeVisible();
});
expect(mockFetch).toHaveBeenCalledTimes(2);

mockUseDevice.mockReturnValue({
status: 2,
myNodeNum: 4660,
hardware: { pioEnv: "tbeam-s3-core" },
metadata: new Map(),
});
rerender(<FirmwareUpdateReminder />);

await waitFor(() => {
expect(screen.queryByText("Firmware update available")).toBeNull();
});
});
});
101 changes: 101 additions & 0 deletions apps/web/src/components/FirmwareUpdateReminder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { FirmwareUpdateNudge } from "@components/FirmwareUpdateNudge.tsx";
import {
buildFirmwareUpdateNotice,
fetchLatestStableFirmwareRelease,
notifyFirmwareUpdateIfPermitted,
type FirmwareReleaseWithTargets,
type FirmwareUpdateNotice,
} from "@core/services/firmwareUpdate.ts";
import { useDevice } from "@core/stores";
import { Types } from "@meshtastic/sdk";
import { useEffect, useMemo, useState } from "react";

export const FirmwareUpdateReminder = () => {
const { hardware, metadata, myNodeNum, status } = useDevice();
const [latestStableRelease, setLatestStableRelease] = useState<
FirmwareReleaseWithTargets | undefined
>(undefined);

const deviceMetadata = metadata.get(0);
const hardwareTarget = hardware.pioEnv.trim();
const isConnected = status === Types.DeviceStatusEnum.DeviceConfigured;

useEffect(() => {
let active = true;
setLatestStableRelease(undefined);

if (!isConnected || !deviceMetadata?.firmwareVersion || !hardwareTarget) {
return;
}

void fetchLatestStableFirmwareRelease().then((release) => {
if (active) setLatestStableRelease(release);
});

return () => {
active = false;
};
}, [isConnected, deviceMetadata?.firmwareVersion, hardwareTarget, myNodeNum]);

const notice = useMemo(
() =>
latestStableRelease &&
hardwareTarget &&
myNodeNum !== undefined &&
deviceMetadata?.firmwareVersion
? buildFirmwareUpdateNotice({
nodeIdentity: myNodeNum.toString(),
hardwareTarget,
currentVersion: deviceMetadata.firmwareVersion,
latestStableVersion: latestStableRelease.id,
releaseTargets: latestStableRelease.targets,
})
: null,
[
deviceMetadata?.firmwareVersion,
hardwareTarget,
latestStableRelease,
myNodeNum,
],
);

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

const BrowserNotification = window.Notification;
notifyFirmwareUpdateIfPermitted(notice, {
notificationPermission: BrowserNotification?.permission,
notification: BrowserNotification
? (title, options) => new BrowserNotification(title, options)
: undefined,
storage: getBrowserStorage(),
onClick: () => {
window.open(notice.actionUrl, "_blank", "noopener,noreferrer");
},
});
}, [notice]);

if (!notice) return null;

return <FirmwareUpdateAction notice={notice} />;
};

const FirmwareUpdateAction = ({ notice }: { notice: FirmwareUpdateNotice }) => (
<aside className="fixed right-6 bottom-6 z-50 w-[min(100%-3rem,26rem)]">
<FirmwareUpdateNudge
currentVersion={notice.currentVersion}
latestStableVersion={notice.latestStableVersion}
onOpen={() => {
window.open(notice.actionUrl, "_blank", "noopener,noreferrer");
}}
/>
</aside>
);

function getBrowserStorage(): Storage | undefined {
try {
return window.localStorage;
} catch {
return;
}
}
Loading
Loading