-
Notifications
You must be signed in to change notification settings - Fork 4
Feat/add update banner to deb package #279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,7 @@ | |
| import './auth/handlers'; | ||
| import './windows/settings'; | ||
| import './windows/process-issues'; | ||
| import './windows'; | ||
|
Check warning on line 23 in src/apps/main/main.ts
|
||
| import './issues/virtual-drive'; | ||
| import './device/handlers'; | ||
| import './../../backend/features/usage/handlers/handlers'; | ||
|
|
@@ -45,6 +45,7 @@ | |
| import { createAuthWindow, getAuthWindow } from './windows/auth'; | ||
| import configStore from './config'; | ||
| import { getTray, setTrayStatus } from './tray/tray'; | ||
| import { broadcastToWindows } from './windows'; | ||
|
Check warning on line 48 in src/apps/main/main.ts
|
||
| import { openOnboardingWindow } from './windows/onboarding'; | ||
| import { setupThemeListener, getTheme } from '../../core/theme'; | ||
| import { installNautilusExtension } from './nautilus-extension/install'; | ||
|
|
@@ -63,6 +64,7 @@ | |
| 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 { checkForUpdatesOnDeb } from '../../core/auto-update/check-for-updates-on-deb'; | ||
|
|
||
| const gotTheLock = app.requestSingleInstanceLock(); | ||
| app.setAsDefaultProtocolClient('internxt'); | ||
|
|
@@ -82,7 +84,18 @@ | |
| osRelease: release(), | ||
| }); | ||
|
|
||
| function checkForUpdates() { | ||
| let pendingUpdateInfo: { version: string } | null = null; | ||
|
|
||
| async function checkForUpdates() { | ||
| if (!process.env.APPIMAGE) { | ||
| const updateInfo = await checkForUpdatesOnDeb({ currentVersion: INTERNXT_VERSION }); | ||
| if (updateInfo) { | ||
| pendingUpdateInfo = updateInfo; | ||
| broadcastToWindows('update-available', updateInfo); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
|
Comment on lines
+87
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could go in its own file instead of main.ts, perhaps inside src/core? Dont forget to add tests |
||
| try { | ||
| autoUpdater.logger = { | ||
| debug: (msg) => logger.debug({ msg: `AutoUpdater: ${msg}` }), | ||
|
|
@@ -96,9 +109,7 @@ | |
| } | ||
| } | ||
|
|
||
| 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 +144,7 @@ | |
| setTrayStatus('IDLE'); | ||
| } | ||
|
|
||
| checkForUpdates(); | ||
| await checkForUpdates(); | ||
| registerAvailableUserProductsHandlers(); | ||
| }) | ||
| .catch((exc) => logger.error({ msg: 'Error starting app', exc })); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This handler is used to check at runtime whether a new version is available for updating. |
||
| ipcRenderer.on(eventName, callbackWrapper); | ||
| return () => ipcRenderer.removeListener(eventName, callbackWrapper); | ||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<UpdateAvailable />); | ||
|
|
||
| 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(<UpdateAvailable />); | ||
|
|
||
| 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(<UpdateAvailable />); | ||
|
|
||
| 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(<UpdateAvailable />); | ||
|
|
||
| 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(<UpdateAvailable />); | ||
| 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(<UpdateAvailable />); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) => { | ||
|
Check warning on line 11 in src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.tsx
|
||
| if (info) setUpdateInfo(info); | ||
| }); | ||
|
|
||
| const unsubscribe = window.electron.onUpdateAvailable((info) => { | ||
|
Check warning on line 15 in src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.tsx
|
||
| setUpdateInfo(info); | ||
| }); | ||
|
|
||
| return unsubscribe; | ||
| }, []); | ||
|
|
||
| if (!updateInfo || dismissed) { | ||
| return <></>; | ||
| } | ||
|
|
||
| const openDownloadPage = () => window.electron.openUrl('https://github.com/internxt/drive-desktop-linux/releases/'); | ||
|
Check warning on line 26 in src/apps/renderer/pages/Widget/InfoBanners/Banners/UpdateAvailable.tsx
|
||
|
|
||
| return ( | ||
| <div className="mx-3 mt-3 flex items-center gap-2.5 rounded-xl border border-primary/30 bg-primary/10 px-4 py-2 dark:bg-primary/20"> | ||
| <Info size={16} weight="fill" className="shrink-0 text-primary" /> | ||
| <div className="flex flex-1 flex-col"> | ||
| <p className="text-xs text-gray-80">{translate('widget.banners.update-available.body')}</p> | ||
| <button | ||
| onClick={openDownloadPage} | ||
| className="self-start text-xs font-medium text-primary underline-offset-2 hover:underline"> | ||
| {translate('widget.banners.update-available.action')} | ||
| </button> | ||
| </div> | ||
| <button | ||
| onClick={() => setDismissed(true)} | ||
| className="shrink-0 text-gray-50 hover:text-gray-80" | ||
| aria-label="Dismiss"> | ||
| <X size={14} /> | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { EventEmitter } from 'events'; | ||
| import { checkForUpdatesOnDeb } from './check-for-updates-on-deb'; | ||
|
|
||
| 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('checkForUpdatesOnDeb', () => { | ||
| 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 checkForUpdatesOnDeb({ 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 checkForUpdatesOnDeb({ 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 checkForUpdatesOnDeb({ 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 checkForUpdatesOnDeb({ 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 checkForUpdatesOnDeb({ 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 checkForUpdatesOnDeb({ currentVersion: '2.5.3' }); | ||
|
|
||
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'./windows' imported multiple times.