From aee78c930d38a430c63eb9f0b5bb679003f867db Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Thu, 3 Sep 2026 12:26:30 -0300 Subject: [PATCH] fix(desktop): announce staged updates reliably and install them silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/desktop/electron-builder.yml | 14 +++- packages/desktop/src/main.ts | 36 +++++++++- packages/desktop/src/preload.ts | 1 + packages/desktop/src/recovery.ts | 14 +++- .../web/src/components/layout/AppLayout.tsx | 1 + .../src/components/ui/UpdateBanner.test.tsx | 54 ++++++++++++++ .../web/src/components/ui/UpdateBanner.tsx | 13 ++++ .../web/src/components/ui/UpdateToast.tsx | 72 +++++-------------- packages/web/src/i18n/locales/en.ts | 4 ++ packages/web/src/i18n/locales/pt-BR.ts | 4 ++ packages/web/src/platform/electron.d.ts | 1 + 11 files changed, 152 insertions(+), 62 deletions(-) create mode 100644 packages/web/src/components/ui/UpdateBanner.test.tsx diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index ea28f6bc..86f7f7b3 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -58,5 +58,15 @@ linux: - deb category: Network nsis: - oneClick: false - allowToChangeInstallationDirectory: true + # One-click, per-user install (%LOCALAPPDATA%), the way Discord ships. + # + # This is what makes an update silent: a per-user install needs no elevation, + # so electron-updater can run the installer with /S and no UAC prompt breaks + # the flow. The assisted installer we had before (oneClick: false) showed its + # wizard on every update and offered an all-users install, which would then + # demand elevation on each subsequent update. + # + # The trade is that a fresh install no longer asks where to put the app. + oneClick: true + perMachine: false + allowToChangeInstallationDirectory: false diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 5e5ce592..1bb3e760 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -36,6 +36,7 @@ import { buildTrayMenuTemplate, buildAppMenuTemplate, type RecoveryState, + RELEASES_URL, } from './recovery'; import { migrateUserData } from './userDataMigration'; @@ -681,12 +682,25 @@ function registerIpcHandlers(): void { ipcMain.on('install-update', () => { try { const { autoUpdater } = require('electron-updater'); - autoUpdater.quitAndInstall(); + quitAndInstallSilently(autoUpdater); } catch { // Auto-updater not available } }); + // The `update-downloaded` push is a one-shot: it reaches whatever page is + // loaded at that instant, and nothing else. It is lost when the window is in + // the tray (destroyed), when the check lands before the web app has mounted + // its listener, and on every reload. Since `downloaded` also latches off + // further polls, the banner could then never appear at all — which is why + // pressing "check for updates" by hand looked like the only thing that + // worked. The renderer now asks for the current state on mount instead of + // relying solely on catching the event live. + ipcMain.handle('get-update-status', () => { + const s = recoveryStore.get(); + return { state: s.updateState, version: s.updateVersion }; + }); + ipcMain.on('check-for-updates', () => { try { const { autoUpdater } = require('electron-updater'); @@ -811,6 +825,22 @@ function registerIpcHandlers(): void { // ─── Auto-Update ──────────────────────────────────────────────────────────── +/** + * Installs a staged update with no installer UI and relaunches the app. + * + * `quitAndInstall()` defaults to (isSilent=false, isForceRunAfter=false): on + * Windows that runs the NSIS installer with its full wizard, so "restart to + * update" meant clicking through a setup program. Silent mode passes /S and + * reuses the recorded install location; isForceRunAfter brings the app back up + * afterwards, which the wizard would otherwise leave to a checkbox. + * + * Both flags are inert on macOS and Linux, where the update is applied by + * swapping files — no installer runs either way. + */ +function quitAndInstallSilently(au: { quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void }): void { + au.quitAndInstall(true, true); +} + function initAutoUpdater(): void { try { const { autoUpdater } = require('electron-updater'); @@ -862,7 +892,7 @@ function initAutoUpdater(): void { showNotification( 'Resenhacord update ready', `Click to restart and install version ${version}.`, - () => autoUpdater.quitAndInstall(), + () => quitAndInstallSilently(autoUpdater), ); } }); @@ -885,7 +915,7 @@ function initAutoUpdater(): void { if (updateConfirmed) { mainWindow?.webContents.send('update-error', { message, - releaseUrl: 'https://github.com/TheZwiss/backspace/releases/latest', + releaseUrl: RELEASES_URL, }); } }); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 92a005cf..07cd0613 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('backspace', { checkForUpdates: () => { ipcRenderer.send('check-for-updates'); }, + getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), getVersion: () => ipcRenderer.invoke('get-app-version'), // Window focus diff --git a/packages/desktop/src/recovery.ts b/packages/desktop/src/recovery.ts index 98030a58..457b8c97 100644 --- a/packages/desktop/src/recovery.ts +++ b/packages/desktop/src/recovery.ts @@ -17,6 +17,15 @@ export type UpdateState = | 'downloaded' | 'error'; +/** + * Where a manual download lands when auto-update fails. + * + * The upstream GitHub repository this used to point at is private for this + * fork, so the link 404'd for everyone who followed it. Releases are on the + * fork's own Gitea, which is also where the updater feed lives. + */ +export const RELEASES_URL = 'https://git.resenha.website/devsyncwrld/backspace/releases'; + export interface RecoveryState { mode: 'normal' | 'recovery'; reason: { code: RecoveryReasonCode; detail: string } | null; @@ -419,7 +428,8 @@ export function handleRecoveryAction(action: RecoveryAction): void { // Direct quitAndInstall — does NOT rely on autoInstallOnAppQuit. // Force-kill-fix: if user reaches recovery and clicks here, install // happens cleanly even if the on-quit hook would otherwise be bypassed. - autoUpdaterRef?.quitAndInstall(); + // Silent + relaunch, matching the in-app banner: no installer wizard. + autoUpdaterRef?.quitAndInstall(true, true); return; } case 'change-instance': { @@ -440,7 +450,7 @@ export function handleRecoveryAction(action: RecoveryAction): void { return; } case 'open-releases': { - shell.openExternal('https://github.com/TheZwiss/backspace/releases/latest'); + shell.openExternal(RELEASES_URL); return; } case 'quit': { diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index c689925a..6eb29d61 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -476,6 +476,7 @@ export function AppLayout() { + {/* User Profile Popout */} diff --git a/packages/web/src/components/ui/UpdateBanner.test.tsx b/packages/web/src/components/ui/UpdateBanner.test.tsx new file mode 100644 index 00000000..d8882c56 --- /dev/null +++ b/packages/web/src/components/ui/UpdateBanner.test.tsx @@ -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(); + + 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(); + + await Promise.resolve(); + expect(container).toBeEmptyDOMElement(); + }); + + it('still reacts to the live event', async () => { + const listeners = installBridge({ state: 'idle', version: null }); + + render(); + await waitFor(() => expect(listeners.length).toBe(1)); + listeners[0]!({ version: '2.3.0' }); + + await waitFor(() => expect(screen.getByText(/2\.3\.0/)).toBeInTheDocument()); + }); +}); diff --git a/packages/web/src/components/ui/UpdateBanner.tsx b/packages/web/src/components/ui/UpdateBanner.tsx index 37ec960b..917f6b32 100644 --- a/packages/web/src/components/ui/UpdateBanner.tsx +++ b/packages/web/src/components/ui/UpdateBanner.tsx @@ -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; diff --git a/packages/web/src/components/ui/UpdateToast.tsx b/packages/web/src/components/ui/UpdateToast.tsx index 91d0b0c8..cdb6d3b9 100644 --- a/packages/web/src/components/ui/UpdateToast.tsx +++ b/packages/web/src/components/ui/UpdateToast.tsx @@ -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(null); + const t = useT(); const [failedUpdate, setFailedUpdate] = useState(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 ( -
-
-
-

Update ready

-

- Version {downloadedVersion} has been downloaded -

-
- - -
-
- ); - } - - // Auto-download failed — show manual download toast return (
-

Update failed

-

- Auto-update failed — download manually -

+

{t('update.failed')}

+

{t('update.failedBody')}

- Download + {t('update.download')}