From 391edb8f928b9f58898e69dfed356373c170c722 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:56:16 +0100 Subject: [PATCH] feat: show the desktop app's updates and channel in the general settings --- .../desktop-update-settings.test.tsx | 125 ++++++++++++++++++ .../settings/desktop-update-settings.tsx | 93 +++++++++++++ components/settings/general-settings.tsx | 3 + lib/desktop/types.ts | 38 ++++++ locales/ar/common.json | 22 +++ locales/ca/common.json | 22 +++ locales/cs/common.json | 22 +++ locales/da/common.json | 22 +++ locales/de/common.json | 22 +++ locales/en/common.json | 22 +++ locales/es/common.json | 22 +++ locales/et/common.json | 22 +++ locales/fa/common.json | 22 +++ locales/fr/common.json | 22 +++ locales/he/common.json | 22 +++ locales/hu/common.json | 22 +++ locales/it/common.json | 22 +++ locales/ja/common.json | 22 +++ locales/ko/common.json | 22 +++ locales/lv/common.json | 22 +++ locales/mn/common.json | 22 +++ locales/nb/common.json | 22 +++ locales/nl/common.json | 22 +++ locales/pl/common.json | 22 +++ locales/pt/common.json | 22 +++ locales/ro/common.json | 22 +++ locales/ru/common.json | 22 +++ locales/sk/common.json | 22 +++ locales/th/common.json | 22 +++ locales/tr/common.json | 22 +++ locales/uk/common.json | 22 +++ locales/vi/common.json | 22 +++ locales/zh-TW/common.json | 22 +++ locales/zh/common.json | 22 +++ 34 files changed, 919 insertions(+) create mode 100644 components/settings/__tests__/desktop-update-settings.test.tsx create mode 100644 components/settings/desktop-update-settings.tsx diff --git a/components/settings/__tests__/desktop-update-settings.test.tsx b/components/settings/__tests__/desktop-update-settings.test.tsx new file mode 100644 index 000000000..95c591c69 --- /dev/null +++ b/components/settings/__tests__/desktop-update-settings.test.tsx @@ -0,0 +1,125 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopUpdateState, DesktopUpdatesBridge } from '@/lib/desktop/types'; +import { DesktopUpdateSettings } from '../desktop-update-settings'; + +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const base: DesktopUpdateState = { + version: '1.0.0', + channel: 'stable', + channelLocked: false, + allowed: true, + ability: 'install', + perMachine: false, + status: 'idle', +}; + +function bridge(state: Partial) { + const full = { ...base, ...state }; + let push: (next: DesktopUpdateState) => void = () => {}; + const updates: DesktopUpdatesBridge = { + state: vi.fn(async () => full), + check: vi.fn(async () => full), + setChannel: vi.fn(async (channel) => ({ ...full, channel })), + installOlder: vi.fn(async () => full), + restart: vi.fn(), + openDownload: vi.fn(), + onChanged: (listener) => { + push = listener; + return () => {}; + }, + }; + window.bulwarkDesktop = { version: 1, platform: 'win32', updates } as BulwarkDesktopBridge; + return { updates, push: (next: Partial) => act(() => push({ ...full, ...next })) }; +} + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +describe('updates in the settings', () => { + it('shows nothing in a browser', () => { + const { container } = render(); + expect(container.textContent).toBe(''); + }); + + it('shows nothing in a copy that cannot update', async () => { + const { updates } = bridge({ ability: 'none' }); + const { container } = render(); + await waitFor(() => expect(updates.state).toHaveBeenCalled()); + await act(async () => {}); + expect(container.textContent).toBe(''); + }); + + it('offers to restart once an update is ready, and only then', async () => { + const { updates, push } = bridge({}); + render(); + await screen.findByRole('button', { name: 'check' }); + expect(screen.queryByRole('button', { name: 'restart' })).toBeNull(); + + push({ status: 'ready', target: '1.1.0' }); + fireEvent.click(await screen.findByRole('button', { name: 'restart' })); + expect(updates.restart).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: 'check' })).toBeNull(); + }); + + it('can check again, but not while it is already checking or downloading', async () => { + const { updates, push } = bridge({}); + render(); + const check = await screen.findByRole('button', { name: 'check' }); + fireEvent.click(check); + expect(updates.check).toHaveBeenCalledTimes(1); + push({ status: 'downloading', target: '1.1.0', percent: 20 }); + await waitFor(() => expect(screen.getByRole('button', { name: 'check' }).disabled).toBe(true)); + }); + + it('switches the channel through the shell', async () => { + const { updates } = bridge({}); + render(); + const select = await screen.findByRole('combobox'); + fireEvent.change(select, { target: { value: 'beta' } }); + expect(updates.setChannel).toHaveBeenCalledWith('beta'); + }); + + it('marks the channel as set by the organisation when a policy locks it', async () => { + bridge({ channelLocked: true, channel: 'beta' }); + render(); + const select = await screen.findByRole('combobox'); + expect(select.value).toBe('beta'); + expect(select.closest('[inert]')).not.toBeNull(); + }); + + it('downgrades only on a click, and only where this copy can install it', async () => { + const { updates, push } = bridge({ status: 'older-available', target: '0.9.0', version: '1.0.0-beta.1' }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'install_older' })); + expect(updates.installOlder).toHaveBeenCalledTimes(1); + + push({ status: 'older-available', target: '0.9.0', ability: 'notify' }); + await waitFor(() => expect(screen.queryByRole('button', { name: 'install_older' })).toBeNull()); + }); + + it('on an MSI it says a version exists and offers no download to a user who cannot install it', async () => { + bridge({ status: 'available', target: '1.1.0', ability: 'notify', perMachine: true }); + render(); + await screen.findByText('status_available_per_machine'); + expect(screen.queryByRole('button', { name: 'download' })).toBeNull(); + }); + + it('links to the download where the user installs it themselves', async () => { + const { updates } = bridge({ status: 'available', target: '1.1.0', ability: 'notify' }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'download' })); + expect(updates.openDownload).toHaveBeenCalledTimes(1); + }); + + it('says the organisation turned updates off', async () => { + bridge({ status: 'off', allowed: false }); + render(); + await screen.findByText('status_off'); + }); +}); diff --git a/components/settings/desktop-update-settings.tsx b/components/settings/desktop-update-settings.tsx new file mode 100644 index 000000000..7a90a8aa0 --- /dev/null +++ b/components/settings/desktop-update-settings.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopUpdateChannel, DesktopUpdateState } from "@/lib/desktop/types"; +import { Select, SettingItem, SettingsSection } from "./settings-section"; + +/** + * The desktop app's updates: version, what's going on, and stable or beta. Only there, + * and only in a copy that can get updates: a browser has nothing to show. + */ +export function DesktopUpdateSettings() { + const t = useTranslations("desktop_updates"); + const [state, setState] = useState(null); + + useEffect(() => { + const updates = getDesktopBridge()?.updates; + if (!updates) return; + let live = true; + void updates.state().then((value) => { + if (live) setState(value); + }); + const stop = updates.onChanged(setState); + return () => { + live = false; + stop(); + }; + }, []); + + const updates = getDesktopBridge()?.updates; + if (!state || !updates || state.ability === "none") return null; + + const version = state.target ?? state.version; + const busy = state.status === "checking" || state.status === "downloading"; + const statusText = (() => { + switch (state.status) { + case "checking": + return t("status_checking"); + case "up-to-date": + return t("status_up_to_date"); + case "downloading": + return t("status_downloading", { version }); + case "ready": + return t("status_ready", { version }); + case "available": + return state.perMachine ? t("status_available_per_machine", { version }) : t("status_available", { version }); + case "older-available": + return t("status_older", { version }); + case "error": + return t("status_error"); + case "off": + return t("status_off"); + default: + return t("status_idle"); + } + })(); + + return ( + + + {state.status === "ready" ? ( + + ) : state.status === "available" && !state.perMachine ? ( + + ) : state.status === "older-available" && state.ability === "install" ? ( + + ) : ( + + )} + + +