diff --git a/src/apps/main/auto-update/check-for-updates.test.ts b/src/apps/main/auto-update/check-for-updates.test.ts new file mode 100644 index 000000000..f753699c9 --- /dev/null +++ b/src/apps/main/auto-update/check-for-updates.test.ts @@ -0,0 +1,94 @@ +import * as checkForUpdatesOnGithubModule from '../../../core/auto-update/check-for-updates-on-github'; +import * as autoUpdateModule from 'electron-updater'; +import { partialSpyOn } from 'tests/vitest/utils.helper'; +import { checkForUpdates } from './check-for-updates'; + +vi.mock('electron-updater', () => ({ + autoUpdater: { + checkForUpdatesAndNotify: vi.fn(), + logger: null, + }, +})); + +vi.mock('../../../core/auto-update/check-for-updates-on-github'); + +describe('checkForUpdates', () => { + const checkForUpdatesOnGithubMock = partialSpyOn(checkForUpdatesOnGithubModule, 'checkForUpdatesOnGithub'); + const checkForUpdatesAndNotifyMock = partialSpyOn(autoUpdateModule.autoUpdater, 'checkForUpdatesAndNotify'); + + const onUpdateAvailable = vi.fn(); + + const defaultProps = { + currentVersion: '2.5.3', + onUpdateAvailable, + }; + + beforeEach(() => { + delete process.env.APPIMAGE; + onUpdateAvailable.mockClear(); + }); + + describe('when running as .deb (no APPIMAGE env var)', () => { + describe('when an update is available', () => { + it('calls onUpdateAvailable with the update info', async () => { + checkForUpdatesOnGithubMock.mockResolvedValue({ version: '3.0.0' }); + + await checkForUpdates(defaultProps); + + expect(onUpdateAvailable).toHaveBeenCalledWith({ version: '3.0.0' }); + }); + }); + + describe('when already up to date', () => { + it('does not call onUpdateAvailable', async () => { + checkForUpdatesOnGithubMock.mockResolvedValue(null); + + await checkForUpdates(defaultProps); + + expect(onUpdateAvailable).not.toHaveBeenCalled(); + }); + }); + + it('does not call autoUpdater.checkForUpdatesAndNotify', async () => { + checkForUpdatesOnGithubMock.mockResolvedValue(null); + + await checkForUpdates(defaultProps); + + expect(checkForUpdatesAndNotifyMock).not.toHaveBeenCalled(); + }); + }); + + describe('when running as .AppImage', () => { + beforeEach(() => { + process.env.APPIMAGE = '/path/to/app.AppImage'; + }); + + it('calls autoUpdater.checkForUpdatesAndNotify', async () => { + await checkForUpdates(defaultProps); + + expect(checkForUpdatesAndNotifyMock).toHaveBeenCalled(); + }); + + it('does not call checkForUpdatesOnGithub', async () => { + await checkForUpdates(defaultProps); + + expect(checkForUpdatesOnGithubMock).not.toHaveBeenCalled(); + }); + + it('does not call onUpdateAvailable', async () => { + await checkForUpdates(defaultProps); + + expect(onUpdateAvailable).not.toHaveBeenCalled(); + }); + + describe('when autoUpdater throws', () => { + it('handles the error gracefully without throwing', async () => { + checkForUpdatesAndNotifyMock.mockImplementation(() => { + throw new Error('update error'); + }); + + await expect(checkForUpdates(defaultProps)).resolves.toBeUndefined(); + }); + }); + }); +}); diff --git a/src/apps/main/auto-update/check-for-updates.ts b/src/apps/main/auto-update/check-for-updates.ts new file mode 100644 index 000000000..2984f6c3e --- /dev/null +++ b/src/apps/main/auto-update/check-for-updates.ts @@ -0,0 +1,30 @@ +import { autoUpdater } from 'electron-updater'; +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { checkForUpdatesOnGithub } from '../../../core/auto-update/check-for-updates-on-github'; + +type Props = { + currentVersion: string; + onUpdateAvailable: (updateInfo: { version: string }) => void; +}; + +export async function checkForUpdates({ currentVersion, onUpdateAvailable }: Props): Promise { + if (!process.env.APPIMAGE) { + const updateInfo = await checkForUpdatesOnGithub({ currentVersion }); + if (updateInfo) { + onUpdateAvailable(updateInfo); + } + return; + } + + try { + autoUpdater.logger = { + debug: (msg) => logger.debug({ msg: `AutoUpdater: ${msg}` }), + info: (msg) => logger.debug({ msg: `AutoUpdater: ${msg}` }), + error: (msg) => logger.error({ msg: `AutoUpdater: ${msg}` }), + warn: (msg) => logger.warn({ msg: `AutoUpdater: ${msg}` }), + }; + await autoUpdater.checkForUpdatesAndNotify(); + } catch (err: unknown) { + logger.error({ msg: 'AutoUpdater Error:', err }); + } +} diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 5e149d92b..a5fa95a77 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -131,6 +131,8 @@ export interface IElectronAPI { getBackupFatalErrors(): Promise>; onBackupProgress(func: (value: number) => void): () => void; startRemoteSync(): Promise; + getUpdateStatus(): Promise<{ version: string } | null>; + onUpdateAvailable(callback: (info: { version: string }) => void): () => void; pathChanged(path: string): void; isUserLoggedIn(): Promise; diff --git a/src/apps/main/main.ts b/src/apps/main/main.ts index d4bdee974..e21dc915a 100644 --- a/src/apps/main/main.ts +++ b/src/apps/main/main.ts @@ -20,7 +20,6 @@ import './auto-launch/handlers'; import './auth/handlers'; import './windows/settings'; import './windows/process-issues'; -import './windows'; import './issues/virtual-drive'; import './device/handlers'; import './../../backend/features/usage/handlers/handlers'; @@ -37,7 +36,6 @@ import './../../backend/features/cleaner/ipc/handlers'; import './virtual-drive'; import { app, ipcMain } from 'electron'; -import { autoUpdater } from 'electron-updater'; import eventBus from './event-bus'; import { AppDataSource } from './database/data-source'; import { getIsLoggedIn } from './auth/handlers'; @@ -45,6 +43,7 @@ import { getOrCreateWidged, getWidget, setBoundsOfWidgetByPath } from './windows import { createAuthWindow, getAuthWindow } from './windows/auth'; import configStore from './config'; import { getTray, setTrayStatus } from './tray/tray'; +import { broadcastToWindows } from './windows'; import { openOnboardingWindow } from './windows/onboarding'; import { setupThemeListener, getTheme } from '../../core/theme'; import { installNautilusExtension } from './nautilus-extension/install'; @@ -63,6 +62,7 @@ import { version, release } from 'node:os'; import { INTERNXT_VERSION } from '../../core/utils/utils'; import { registerBackupHandlers } from '../../backend/features/backup/register-backup-handlers'; import { startBackupsIfAvailable } from '../../backend/features/backup/start-backups-if-available'; +import { checkForUpdates } from './auto-update/check-for-updates'; const gotTheLock = app.requestSingleInstanceLock(); app.setAsDefaultProtocolClient('internxt'); @@ -82,23 +82,9 @@ logger.debug({ osRelease: release(), }); -function checkForUpdates() { - try { - autoUpdater.logger = { - debug: (msg) => logger.debug({ msg: `AutoUpdater: ${msg}` }), - info: (msg) => logger.debug({ msg: `AutoUpdater: ${msg}` }), - error: (msg) => logger.error({ msg: `AutoUpdater: ${msg}` }), - warn: (msg) => logger.warn({ msg: `AutoUpdater: ${msg}` }), - }; - autoUpdater.checkForUpdatesAndNotify(); - } catch (err: unknown) { - logger.error({ msg: 'AutoUpdater Error:', err }); - } -} +let pendingUpdateInfo: { version: string } | null = null; -if (process.platform === 'darwin') { - app.dock.hide(); -} +ipcMain.handle('get-update-status', () => pendingUpdateInfo); if (process.env.NODE_ENV === 'production') { // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -133,7 +119,13 @@ app setTrayStatus('IDLE'); } - checkForUpdates(); + await checkForUpdates({ + currentVersion: INTERNXT_VERSION, + onUpdateAvailable: (updateInfo) => { + pendingUpdateInfo = updateInfo; + broadcastToWindows('update-available', updateInfo); + }, + }); registerAvailableUserProductsHandlers(); }) .catch((exc) => logger.error({ msg: 'Error starting app', exc })); diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index e74f594ef..4dd88b448 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -214,5 +214,7 @@ declare interface Window { ) => () => void; getDiskSpace: () => Promise; }; + getUpdateStatus(): Promise<{ version: string } | null>; + onUpdateAvailable(callback: (info: { version: string }) => void): () => void; }; } diff --git a/src/apps/main/preload.js b/src/apps/main/preload.js index 082fb5d34..ab7601b0d 100644 --- a/src/apps/main/preload.js +++ b/src/apps/main/preload.js @@ -407,4 +407,13 @@ contextBridge.exposeInMainWorld('electron', { }, getDiskSpace: () => ipcRenderer.invoke('cleaner:get-disk-space'), }, + getUpdateStatus() { + return ipcRenderer.invoke('get-update-status'); + }, + onUpdateAvailable(callback) { + const eventName = 'update-available'; + const callbackWrapper = (_, info) => callback(info); + ipcRenderer.on(eventName, callbackWrapper); + return () => ipcRenderer.removeListener(eventName, callbackWrapper); + }, }); diff --git a/src/apps/renderer/localize/locales/en.json b/src/apps/renderer/localize/locales/en.json index 0903ec408..047738fa9 100644 --- a/src/apps/renderer/localize/locales/en.json +++ b/src/apps/renderer/localize/locales/en.json @@ -186,6 +186,10 @@ "button": "Mount" }, "banners": { + "update-available": { + "body": "A new version of Internxt is available.", + "action": "Download update" + }, "discover-backups": { "title": "INTERNXT BACKUPS", "body": "Keep a lifesaver copy of your most important folders and files.", diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json index cf1ba85b5..0f82dfd9a 100644 --- a/src/apps/renderer/localize/locales/es.json +++ b/src/apps/renderer/localize/locales/es.json @@ -185,6 +185,10 @@ "button": "Montar" }, "banners": { + "update-available": { + "body": "Hay una nueva versión de Internxt disponible.", + "action": "Descargar actualización" + }, "discover-backups": { "title": " COPIAS DE SEGURIDAD DE INTERNXT", "body": "Mantén una copia de seguridad de tus carpetas y archivos más importantes.", diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json index fa90e9893..5ef2471a8 100644 --- a/src/apps/renderer/localize/locales/fr.json +++ b/src/apps/renderer/localize/locales/fr.json @@ -181,6 +181,10 @@ "button": "Monter" }, "banners": { + "update-available": { + "body": "Une nouvelle version d'Internxt est disponible.", + "action": "Télécharger la mise à jour" + }, "discover-backups": { "title": "INTERNXT SAUVEGARDES", "body": "Gardez une copie de secours de vos dossiers et fichiers les plus importants.", diff --git a/src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.test.tsx b/src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.test.tsx new file mode 100644 index 000000000..eb81545a2 --- /dev/null +++ b/src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.test.tsx @@ -0,0 +1,85 @@ +import { act, render, screen, fireEvent } from '@testing-library/react'; +import { UpdateAvailable } from './UpdateAvailable'; + +describe('UpdateAvailable', () => { + beforeEach(() => { + vi.mocked(window.electron.getUpdateStatus).mockResolvedValue(null); + }); + + it('renders nothing when no update is available', () => { + vi.mocked(window.electron.onUpdateAvailable).mockReturnValue(vi.fn()); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the banner when an update is available', () => { + vi.mocked(window.electron.onUpdateAvailable).mockImplementation((callback) => { + callback({ version: '2.6.0' }); + return vi.fn(); + }); + + render(); + + expect(screen.getByText('widget.banners.update-available.body')).toBeInTheDocument(); + expect(screen.getByText('widget.banners.update-available.action')).toBeInTheDocument(); + }); + + it('opens the download page when the action link is clicked', () => { + vi.mocked(window.electron.onUpdateAvailable).mockImplementation((callback) => { + callback({ version: '2.6.0' }); + return vi.fn(); + }); + + render(); + + fireEvent.click(screen.getByText('widget.banners.update-available.action')); + + expect(window.electron.openUrl).toBeCalledWith('https://github.com/internxt/drive-desktop-linux/releases/'); + }); + + it('dismisses the banner when the X button is clicked', () => { + vi.mocked(window.electron.onUpdateAvailable).mockImplementation((callback) => { + callback({ version: '2.6.0' }); + return vi.fn(); + }); + + render(); + + expect(screen.getByText('widget.banners.update-available.body')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Dismiss')); + + expect(screen.queryByText('widget.banners.update-available.body')).not.toBeInTheDocument(); + }); + + it('calls the cleanup function on unmount', () => { + const unsubscribe = vi.fn(); + vi.mocked(window.electron.onUpdateAvailable).mockReturnValue(unsubscribe); + + const { unmount } = render(); + unmount(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('shows the banner when update-available event fires after mount', () => { + let capturedCallback: ((info: { version: string }) => void) | null = null; + + vi.mocked(window.electron.onUpdateAvailable).mockImplementation((callback) => { + capturedCallback = callback; + return vi.fn(); + }); + + render(); + + expect(screen.queryByText('widget.banners.update-available.body')).not.toBeInTheDocument(); + + act(() => { + capturedCallback!({ version: '3.0.0' }); + }); + + expect(screen.getByText('widget.banners.update-available.body')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.tsx b/src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.tsx new file mode 100644 index 000000000..bb8693ffe --- /dev/null +++ b/src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import { useTranslationContext } from '../../../../context/LocalContext'; +import { Info, X } from '@phosphor-icons/react'; + +export function UpdateAvailable() { + const [updateInfo, setUpdateInfo] = useState<{ version: string } | null>(null); + const { translate } = useTranslationContext(); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + window.electron.getUpdateStatus().then((info) => { + if (info) setUpdateInfo(info); + }); + + const unsubscribe = window.electron.onUpdateAvailable((info) => { + setUpdateInfo(info); + }); + + return unsubscribe; + }, []); + + if (!updateInfo || dismissed) { + return <>; + } + + const openDownloadPage = () => window.electron.openUrl('https://github.com/internxt/drive-desktop-linux/releases/'); + + return ( +
+ +
+

{translate('widget.banners.update-available.body')}

+ +
+ +
+ ); +} diff --git a/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx b/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx index ac9b5490f..cc1f10953 100644 --- a/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx +++ b/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx @@ -1,8 +1,10 @@ import { DiscoverBackups } from './Banners/DiscoverBackups'; +import { UpdateAvailable } from './Banners/UpdateAvailable'; export function InfoBanners() { return ( <> + ); diff --git a/src/core/auto-update/check-for-updates-on-github.test.ts b/src/core/auto-update/check-for-updates-on-github.test.ts new file mode 100644 index 000000000..ebfcf50d4 --- /dev/null +++ b/src/core/auto-update/check-for-updates-on-github.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from 'events'; +import { checkForUpdatesOnGithub } from './check-for-updates-on-github'; + +const mockGet = vi.fn(); + +vi.mock('https', () => ({ + default: { get: (...args: unknown[]) => mockGet(...args) }, +})); + +function mockHttpsResponse(statusCode: number, body: string) { + const res = new EventEmitter() as EventEmitter & { statusCode: number }; + res.statusCode = statusCode; + + const req = new EventEmitter(); + + mockGet.mockImplementation((_options, callback) => { + callback(res); + res.emit('data', body); + res.emit('end'); + return req; + }); +} + +function mockHttpsError(error: Error) { + const req = new EventEmitter(); + + mockGet.mockImplementation(() => { + setTimeout(() => req.emit('error', error), 0); + return req; + }); +} + +describe('checkForUpdatesOnGithub', () => { + describe('when a newer version is available', () => { + it('returns the latest version', async () => { + mockHttpsResponse(200, JSON.stringify({ tag_name: 'v3.0.0' })); + + const result = await checkForUpdatesOnGithub({ currentVersion: '2.5.3' }); + + expect(result).toEqual({ version: '3.0.0' }); + }); + + it('strips the leading v from the tag name', async () => { + mockHttpsResponse(200, JSON.stringify({ tag_name: 'v2.6.0' })); + + const result = await checkForUpdatesOnGithub({ currentVersion: '2.5.3' }); + + expect(result).toEqual({ version: '2.6.0' }); + }); + }); + + describe('when the current version is up to date', () => { + it('returns null', async () => { + mockHttpsResponse(200, JSON.stringify({ tag_name: 'v2.5.3' })); + + const result = await checkForUpdatesOnGithub({ currentVersion: '2.5.3' }); + + expect(result).toBeNull(); + }); + }); + + describe('when the GitHub API returns a non-200 status', () => { + it('returns null', async () => { + mockHttpsResponse(403, ''); + + const result = await checkForUpdatesOnGithub({ currentVersion: '2.5.3' }); + + expect(result).toBeNull(); + }); + }); + + describe('when the GitHub API returns invalid JSON', () => { + it('returns null', async () => { + mockHttpsResponse(200, 'not-valid-json'); + + const result = await checkForUpdatesOnGithub({ currentVersion: '2.5.3' }); + + expect(result).toBeNull(); + }); + }); + + describe('when the request fails with a network error', () => { + it('returns null', async () => { + mockHttpsError(new Error('ECONNREFUSED')); + + const result = await checkForUpdatesOnGithub({ currentVersion: '2.5.3' }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/core/auto-update/check-for-updates-on-github.ts b/src/core/auto-update/check-for-updates-on-github.ts new file mode 100644 index 000000000..574774e8f --- /dev/null +++ b/src/core/auto-update/check-for-updates-on-github.ts @@ -0,0 +1,50 @@ +import https from 'node:https'; +import { logger } from '@internxt/drive-desktop-core/build/backend'; + +const GITHUB_API_OPTIONS = { + hostname: 'api.github.com', + path: '/repos/internxt/drive-desktop-linux/releases/latest', + headers: { 'User-Agent': 'internxt-drive-desktop', Accept: 'application/vnd.github.v3+json' }, +}; + +type Props = { + currentVersion: string; +}; + +export async function checkForUpdatesOnGithub({ currentVersion }: Props) { + return new Promise<{ version: string } | null>((resolve) => { + https + .get(GITHUB_API_OPTIONS, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + if (res.statusCode !== 200) { + logger.warn({ msg: `AutoUpdater (github): GitHub API responded with ${res.statusCode}` }); + return resolve(null); + } + const release = JSON.parse(data) as { tag_name: string }; + const latestVersion = release.tag_name.replace(/^v/, ''); + + logger.debug({ msg: 'AutoUpdater (github): version check', latestVersion, currentVersion }); + + if (latestVersion !== currentVersion) { + logger.debug({ msg: 'AutoUpdater (github): update available', version: latestVersion }); + return resolve({ version: latestVersion }); + } + + resolve(null); + } catch (err) { + logger.error({ msg: 'AutoUpdater (github): error parsing response', err }); + resolve(null); + } + }); + }) + .on('error', (err) => { + logger.error({ msg: 'AutoUpdater (github): error checking for updates', err }); + resolve(null); + }); + }); +}