fix(desktop): announce staged updates reliably and install them silently

Two separate reasons "Restart to update" only ever showed up after pressing
"check for updates" by hand, and one reason the restart was unpleasant.

1. `update-downloaded` is a one-shot push to whichever page happens to be
   loaded. It is dropped when the window is in the tray (mainWindow is null),
   when the 10s post-launch check beats the web app's listener registration,
   and on every reload. Once an update is staged the poll latches off, so it
   was never re-announced — a manual check was the only way back. The main
   process already tracks this in recoveryStore, so expose it over IPC and have
   the banner ask on mount rather than rely on catching the event live.

2. UpdateBanner was only mounted in the mobile layout. On the desktop app the
   visible one was UpdateToast, which duplicated the same "update ready" card
   in untranslated English. Mount the banner on desktop too and reduce the
   toast to the failure case it does not share.

3. quitAndInstall() defaults to a non-silent install, so on Windows restarting
   meant clicking through the NSIS wizard. Pass silent + force-run-after, and
   switch the installer to one-click per-user, which is what lets the silent
   install proceed without an elevation prompt.

The manual-download link in the failure toast pointed at the upstream GitHub
repository, which is private for this fork and 404'd for everyone. Point it at
the fork's Gitea releases, where the updater feed already lives.

Regression test covers the case the live event never announced; verified by
mutation that removing the status query fails it and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:26:30 -03:00
co-authored by Claude Opus 5
parent 5e7adfb38d
commit aee78c930d
11 changed files with 152 additions and 62 deletions
@@ -476,6 +476,7 @@ export function AppLayout() {
<SoundController />
<GlobalAudioRenderer />
<NotificationController />
<UpdateBanner />
<UpdateToast />
{/* User Profile Popout */}
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UpdateBanner } from './UpdateBanner';
type Status = { state: string; version: string | null };
function installBridge(status: Status) {
const listeners: Array<(info: { version: string }) => void> = [];
(window as unknown as { backspace: unknown }).backspace = {
onUpdateDownloaded: (cb: (info: { version: string }) => void) => listeners.push(cb),
getUpdateStatus: () => Promise.resolve(status),
installUpdate: vi.fn(),
};
return listeners;
}
describe('UpdateBanner', () => {
beforeEach(() => {
delete (window as unknown as { backspace?: unknown }).backspace;
});
it('shows a staged update the live event never announced', async () => {
// The regression: `update-downloaded` is pushed once, to whichever page is
// loaded at that moment. Downloading with the window in the tray, before
// this component mounts, or across a reload used to lose it for good — the
// main process latches off further polls once an update is staged, so the
// banner never came back and only a manual check surfaced it.
installBridge({ state: 'downloaded', version: '2.2.0' });
render(<UpdateBanner />);
await waitFor(() => expect(screen.getByText(/2\.2\.0/)).toBeInTheDocument());
});
it('stays hidden when nothing is staged', async () => {
installBridge({ state: 'idle', version: null });
const { container } = render(<UpdateBanner />);
await Promise.resolve();
expect(container).toBeEmptyDOMElement();
});
it('still reacts to the live event', async () => {
const listeners = installBridge({ state: 'idle', version: null });
render(<UpdateBanner />);
await waitFor(() => expect(listeners.length).toBe(1));
listeners[0]!({ version: '2.3.0' });
await waitFor(() => expect(screen.getByText(/2\.3\.0/)).toBeInTheDocument());
});
});
@@ -22,6 +22,19 @@ export function UpdateBanner() {
// dispensar significa "agora não", não "nunca mais".
setDismissed(false);
});
// O evento acima é disparado uma única vez, para a página que estiver
// carregada naquele instante — se o download terminar com a janela na
// bandeja, antes deste componente montar, ou se a página recarregar, ele se
// perde e o aviso nunca aparece. Por isso perguntamos o estado atual ao
// montar, em vez de depender só de pegar o evento ao vivo.
let alive = true;
window.backspace.getUpdateStatus?.()
.then((status) => {
if (alive && status.state === 'downloaded' && status.version) setVersion(status.version);
})
.catch(() => { /* versão antiga do app sem esse IPC — segue só com o evento */ });
return () => { alive = false; };
}, []);
if (!version || dismissed) return null;
+17 -55
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { isElectron } from '../../platform/platform';
import { useT } from '../../i18n';
interface UpdateError {
message: string;
@@ -7,84 +8,45 @@ interface UpdateError {
}
/**
* Persistent toast shown when an Electron auto-update has been downloaded
* or when auto-update fails (offers manual download link).
* Renders nothing in browser environments.
* Aviso de falha na atualização automática, com link para baixar à mão.
*
* O caso de sucesso ("já baixou, é só reiniciar") ficava aqui também, e
* aparecia junto com o UpdateBanner — dois avisos da mesma coisa, um deles em
* inglês. Esse pedaço saiu; sucesso agora é só do UpdateBanner.
*/
export function UpdateToast() {
const [downloadedVersion, setDownloadedVersion] = useState<string | null>(null);
const t = useT();
const [failedUpdate, setFailedUpdate] = useState<UpdateError | null>(null);
useEffect(() => {
if (!isElectron() || !window.backspace) return;
window.backspace.onUpdateDownloaded((info) => {
setDownloadedVersion(info.version);
// Auto-download succeeded — clear any previous error state
setFailedUpdate(null);
});
window.backspace.onUpdateError((error) => {
setFailedUpdate(error);
});
window.backspace.onUpdateError((error) => setFailedUpdate(error));
// Um download que dá certo apaga o erro anterior: o aviso de falha não pode
// sobreviver a uma tentativa bem-sucedida.
window.backspace.onUpdateDownloaded(() => setFailedUpdate(null));
}, []);
// Nothing to show
if (!downloadedVersion && !failedUpdate) return null;
if (!failedUpdate) return null;
// Auto-download succeeded — show restart toast
if (downloadedVersion) {
return (
<div className="fixed bottom-6 left-6 z-[300] animate-slide-up">
<div className="glass-pill rounded-xl px-4 py-3 flex items-center gap-3 max-w-[340px]">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-txt-primary">Update ready</p>
<p className="text-xs text-txt-secondary truncate">
Version {downloadedVersion} has been downloaded
</p>
</div>
<button
onClick={() => window.backspace?.installUpdate()}
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-primary hover:bg-accent-primary/80 text-white transition-colors"
>
Restart
</button>
<button
onClick={() => setDownloadedVersion(null)}
className="shrink-0 p-1 text-txt-tertiary hover:text-txt-secondary transition-colors"
aria-label="Dismiss"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}
// Auto-download failed — show manual download toast
return (
<div className="fixed bottom-6 left-6 z-[300] animate-slide-up">
<div className="glass-pill rounded-xl px-4 py-3 flex items-center gap-3 max-w-[380px]">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-txt-primary">Update failed</p>
<p className="text-xs text-txt-secondary truncate">
Auto-update failed — download manually
</p>
<p className="text-sm font-medium text-txt-primary">{t('update.failed')}</p>
<p className="text-xs text-txt-secondary truncate">{t('update.failedBody')}</p>
</div>
<a
href={failedUpdate!.releaseUrl}
href={failedUpdate.releaseUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-primary hover:bg-accent-primary/80 text-white transition-colors"
>
Download
{t('update.download')}
</a>
<button
onClick={() => setFailedUpdate(null)}
className="shrink-0 p-1 text-txt-tertiary hover:text-txt-secondary transition-colors"
aria-label="Dismiss"
aria-label={t('update.dismiss')}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+4
View File
@@ -65,6 +65,10 @@ export const en = {
'update.readyVersion': 'Version {version} is ready to install.',
'update.restart': 'Restart to update',
'update.later': 'Later',
'update.failed': 'Update failed',
'update.failedBody': 'Auto-update failed — download it manually.',
'update.download': 'Download',
'update.dismiss': 'Dismiss',
// Custom emojis and stickers
'expressions.title': 'Emojis & Stickers',
+4
View File
@@ -64,6 +64,10 @@ export const ptBR: Partial<Dictionary> = {
'update.readyVersion': 'A versão {version} está pronta para instalar.',
'update.restart': 'Reiniciar para atualizar',
'update.later': 'Depois',
'update.failed': 'Falha ao atualizar',
'update.failedBody': 'A atualização automática falhou — baixe manualmente.',
'update.download': 'Baixar',
'update.dismiss': 'Dispensar',
// Emojis e figurinhas
'expressions.title': 'Emojis e figurinhas',
+1
View File
@@ -46,6 +46,7 @@ interface BackspaceElectronAPI {
onUpdateError: (callback: (error: { message: string; releaseUrl: string }) => void) => void;
installUpdate: () => void;
checkForUpdates: () => void;
getUpdateStatus: () => Promise<{ state: 'idle' | 'checking' | 'downloading' | 'downloaded' | 'error'; version: string | null }>;
getVersion: () => Promise<string>;
// Window focus (Task 2.2)