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
-