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')}