From fdfade84df68040cda882e7f70eaf4f2373b4e69 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Wed, 10 Jun 2026 15:29:02 -0500 Subject: [PATCH 1/4] feat: add Nautilus availability check and corresponding UI banner --- src/apps/main/interface.d.ts | 1 + src/apps/main/preload.d.ts | 1 + src/apps/main/preload.js | 3 + src/apps/renderer/localize/locales/en.json | 6 +- src/apps/renderer/localize/locales/es.json | 6 +- src/apps/renderer/localize/locales/fr.json | 6 +- .../Banners/NautilusUnavailable.test.tsx | 42 +++++++ .../Banners/NautilusUnavailable.tsx | 39 +++++++ .../pages/Widget/InfoBanners/InfoBanners.tsx | 2 + .../features/nautilus-extension/install.ts | 12 +- .../is-nautilus-available.test.ts | 103 ++++++++++++++++++ .../is-nautilus-available.ts | 42 +++++++ .../bootstrap/register-main-ipc-handlers.ts | 2 + 13 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.test.tsx create mode 100644 src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.tsx create mode 100644 src/backend/features/nautilus-extension/is-nautilus-available.test.ts create mode 100644 src/backend/features/nautilus-extension/is-nautilus-available.ts diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 260428a777..5b32bea616 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -157,6 +157,7 @@ export interface IElectronAPI { onBackupProgress(func: (value: number) => void): () => void; startRemoteSync(): Promise; getUpdateStatus(): Promise<{ version: string } | null>; + getNautilusAvailability(): Promise; onUpdateAvailable(callback: (info: { version: string }) => void): () => void; getRemoteSyncStatus(): Promise; onRemoteSyncStatusChange(callback: (status: import('./remote-sync/helpers').RemoteSyncStatus) => void): () => void; diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index 2a57746063..b841a68672 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -218,6 +218,7 @@ declare interface Window { getDiskSpace: () => Promise; }; getUpdateStatus(): Promise<{ version: string } | null>; + getNautilusAvailability(): Promise; onUpdateAvailable(callback: (info: { version: string }) => void): () => void; }; } diff --git a/src/apps/main/preload.js b/src/apps/main/preload.js index 170be45c3c..45947da440 100644 --- a/src/apps/main/preload.js +++ b/src/apps/main/preload.js @@ -409,6 +409,9 @@ contextBridge.exposeInMainWorld('electron', { getUpdateStatus() { return ipcRenderer.invoke('get-update-status'); }, + getNautilusAvailability() { + return ipcRenderer.invoke('get-nautilus-availability'); + }, onUpdateAvailable(callback) { const eventName = 'update-available'; const callbackWrapper = (_, info) => callback(info); diff --git a/src/apps/renderer/localize/locales/en.json b/src/apps/renderer/localize/locales/en.json index 72b99c2299..e82082df6c 100644 --- a/src/apps/renderer/localize/locales/en.json +++ b/src/apps/renderer/localize/locales/en.json @@ -108,6 +108,10 @@ "message": "We are having issues mounting your Internxt Drive, try unmounting it manually and starting the app again" }, "banners": { + "nautilus-unavailable": { + "body": "File explorer not supported. Some features may not work properly.", + "action": "Learn more" + }, "update-available": { "body": "A new version is available.", "action": "Download update" @@ -434,4 +438,4 @@ "ctaUpgrade": "Upgrade plan", "plan": "{{planName}} -> up to {{planMaxFileSize}}" } -} +} \ No newline at end of file diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json index b5300ac717..06b5a6b71c 100644 --- a/src/apps/renderer/localize/locales/es.json +++ b/src/apps/renderer/localize/locales/es.json @@ -108,6 +108,10 @@ "message": "Estamos teniendo problemas al montar tu unidad Internxt. Intenta desmontarla manualmente y luego reiniciar la aplicación." }, "banners": { + "nautilus-unavailable": { + "body": "Explorador de archivos no compatible. Algunas funciones pueden no funcionar correctamente.", + "action": "Más información" + }, "update-available": { "body": "Hay una nueva versión disponible.", "action": "Descargar actualización" @@ -434,4 +438,4 @@ "ctaUpgrade": "Mejorar plan", "plan": "{{planName}} -> hasta {{planMaxFileSize}}" } -} +} \ No newline at end of file diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json index 7cccf062aa..fbaad9639a 100644 --- a/src/apps/renderer/localize/locales/fr.json +++ b/src/apps/renderer/localize/locales/fr.json @@ -108,6 +108,10 @@ "message": "Nous rencontrons des problèmes pour monter votre disque Internxt. Essayez de le démonter manuellement et de relancer l'application. " }, "banners": { + "nautilus-unavailable": { + "body": "Explorateur de fichiers non pris en charge. Certaines fonctionnalités peuvent ne pas fonctionner correctement.", + "action": "En savoir plus" + }, "update-available": { "body": "Une nouvelle version est disponible.", "action": "Télécharger la mise à jour" @@ -434,4 +438,4 @@ "ctaUpgrade": "Mettre à jour le plan", "plan": "{{planName}} -> jusqu'à {{planMaxFileSize}}" } -} +} \ No newline at end of file diff --git a/src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.test.tsx b/src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.test.tsx new file mode 100644 index 0000000000..fa5488be17 --- /dev/null +++ b/src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { NautilusUnavailable } from './NautilusUnavailable'; + +describe('NautilusUnavailable', () => { + beforeEach(() => { + vi.mocked(window.electron.getNautilusAvailability).mockResolvedValue(true); + }); + + it('renders nothing when Nautilus is available', async () => { + const { container } = render(); + + await waitFor(() => { + expect(window.electron.getNautilusAvailability).toHaveBeenCalledTimes(1); + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the warning banner when Nautilus is not available', async () => { + vi.mocked(window.electron.getNautilusAvailability).mockResolvedValue(false); + + render(); + + await waitFor(() => { + expect(screen.getByText('widget.banners.nautilus-unavailable.body')).toBeInTheDocument(); + }); + }); + + it('dismisses the warning banner when the X button is clicked', async () => { + vi.mocked(window.electron.getNautilusAvailability).mockResolvedValue(false); + + render(); + + await waitFor(() => { + expect(screen.getByText('widget.banners.nautilus-unavailable.body')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText('Dismiss')); + + expect(screen.queryByText('widget.banners.nautilus-unavailable.body')).not.toBeInTheDocument(); + }); +}); diff --git a/src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.tsx b/src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.tsx new file mode 100644 index 0000000000..e0cc384abe --- /dev/null +++ b/src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.tsx @@ -0,0 +1,39 @@ +import { Warning, X } from '@phosphor-icons/react'; +import { useEffect, useState } from 'react'; +import { useTranslationContext } from '../../../../context/LocalContext'; + +export function NautilusUnavailable() { + const [isNautilusAvailable, setIsNautilusAvailable] = useState(true); + const [dismissed, setDismissed] = useState(false); + const { translate } = useTranslationContext(); + + useEffect(() => { + window.electron + .getNautilusAvailability() + .then((isAvailable) => { + setIsNautilusAvailable(isAvailable); + }) + .catch(() => { + setIsNautilusAvailable(true); + }); + }, []); + + if (isNautilusAvailable || dismissed) { + return <>; + } + + return ( +
+ +
+

{translate('widget.banners.nautilus-unavailable.body')}

+
+ +
+ ); +} diff --git a/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx b/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx index cc1f109532..0c3cd952f7 100644 --- a/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx +++ b/src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx @@ -1,9 +1,11 @@ import { DiscoverBackups } from './Banners/DiscoverBackups'; +import { NautilusUnavailable } from './Banners/NautilusUnavailable'; import { UpdateAvailable } from './Banners/UpdateAvailable'; export function InfoBanners() { return ( <> + diff --git a/src/backend/features/nautilus-extension/install.ts b/src/backend/features/nautilus-extension/install.ts index 448d6f5dbf..528c2d7f6b 100644 --- a/src/backend/features/nautilus-extension/install.ts +++ b/src/backend/features/nautilus-extension/install.ts @@ -1,5 +1,11 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { copyNautilusExtensionFile, deleteNautilusExtensionFile, isInstalled, reloadNautilus } from './service'; +import { + copyNautilusExtensionFile, + deleteNautilusExtensionFile, + isInstalled, + reloadNautilus, +} from './service'; +import { isNautilusAvailable } from './is-nautilus-available'; import configStore from '../../../apps/main/config'; import { LATEST_NAUTILUS_EXTENSION_VERSION } from './version'; @@ -26,6 +32,10 @@ async function install(): Promise { export async function installNautilusExtension() { try { + const canInstall = await isNautilusAvailable(); + + if (!canInstall) return; + const installed = await isInstalled(); const hasLatestsVersion = isUpToDate(); diff --git a/src/backend/features/nautilus-extension/is-nautilus-available.test.ts b/src/backend/features/nautilus-extension/is-nautilus-available.test.ts new file mode 100644 index 0000000000..43a4f2e228 --- /dev/null +++ b/src/backend/features/nautilus-extension/is-nautilus-available.test.ts @@ -0,0 +1,103 @@ +const { execAsyncMock } = vi.hoisted(() => ({ + execAsyncMock: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + exec: vi.fn(), +})); + +vi.mock('node:util', () => ({ + promisify: vi.fn(() => execAsyncMock), +})); + +import { isNautilusAvailable } from './is-nautilus-available'; + +type ExecAsyncResult = { + stdout: string; + stderr: string; +}; + +type Pops = { + desktopEntry: string; + hasNautilusBinary: boolean; +}; + +function mockExecWith({ desktopEntry, hasNautilusBinary }: Pops) { + execAsyncMock.mockImplementation(async (command: string) => { + if (command === 'xdg-mime query default inode/directory') { + return { + stdout: `${desktopEntry}\n`, + stderr: '', + } as ExecAsyncResult; + } + + if (command === 'command -v nautilus') { + if (hasNautilusBinary) { + return { + stdout: '/usr/bin/nautilus\n', + stderr: '', + } as ExecAsyncResult; + } else { + throw new Error('nautilus not found'); + } + } + + throw new Error(`Unexpected command: ${command}`); + }); +} + +describe('is-nautilus-available', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns true when nautilus is default and binary exists', async () => { + mockExecWith({ + desktopEntry: 'org.gnome.Nautilus.desktop', + hasNautilusBinary: true, + }); + + const result = await isNautilusAvailable(); + + expect(result).toBe(true); + expect(execAsyncMock).toHaveBeenNthCalledWith(1, 'xdg-mime query default inode/directory'); + expect(execAsyncMock).toHaveBeenNthCalledWith(2, 'command -v nautilus'); + }); + + it('returns false when default explorer is not nautilus', async () => { + mockExecWith({ + desktopEntry: 'nemo.desktop', + hasNautilusBinary: true, + }); + + const result = await isNautilusAvailable(); + + expect(result).toBe(false); + expect(execAsyncMock).toHaveBeenCalledTimes(1); + expect(execAsyncMock).toHaveBeenCalledWith('xdg-mime query default inode/directory'); + }); + + it('returns false when nautilus binary is missing', async () => { + mockExecWith({ + desktopEntry: 'org.gnome.Nautilus.desktop', + hasNautilusBinary: false, + }); + + const result = await isNautilusAvailable(); + + expect(result).toBe(false); + expect(execAsyncMock).toHaveBeenCalledTimes(2); + }); + + it('returns true when default desktop entry is empty and nautilus binary exists', async () => { + mockExecWith({ + desktopEntry: '', + hasNautilusBinary: true, + }); + + const result = await isNautilusAvailable(); + + expect(result).toBe(true); + expect(execAsyncMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/backend/features/nautilus-extension/is-nautilus-available.ts b/src/backend/features/nautilus-extension/is-nautilus-available.ts new file mode 100644 index 0000000000..33596cce00 --- /dev/null +++ b/src/backend/features/nautilus-extension/is-nautilus-available.ts @@ -0,0 +1,42 @@ +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +export async function isNautilusAvailable() { + const desktopEntry = await getDefaultDirectoryDesktopEntry(); + + if (desktopEntry && !desktopEntryUsesNautilus({ desktopEntry })) { + return false; + } + + const hasNautilus = await hasNautilusBinary(); + if (!hasNautilus) { + return false; + } + + return true; +} + +async function getDefaultDirectoryDesktopEntry() { + try { + const { stdout } = await execAsync('xdg-mime query default inode/directory'); + + return stdout.trim().toLowerCase(); + } catch { + return ''; + } +} + +async function hasNautilusBinary() { + try { + await execAsync('command -v nautilus'); + return true; + } catch { + return false; + } +} + +function desktopEntryUsesNautilus({ desktopEntry }: { desktopEntry: string }) { + return desktopEntry.includes('nautilus.desktop'); +} \ No newline at end of file diff --git a/src/core/bootstrap/register-main-ipc-handlers.ts b/src/core/bootstrap/register-main-ipc-handlers.ts index ae8c8b06d1..4761e28a9e 100644 --- a/src/core/bootstrap/register-main-ipc-handlers.ts +++ b/src/core/bootstrap/register-main-ipc-handlers.ts @@ -1,9 +1,11 @@ import dns from 'node:dns'; import { ipcMain } from 'electron'; +import { isNautilusAvailable } from '../../apps/main/nautilus-extension/is-nautilus-available'; import { getPendingUpdateInfo } from './bootstrap-runtime-state'; export function registerMainIpcHandlers() { ipcMain.handle('get-update-status', () => getPendingUpdateInfo()); + ipcMain.handle('get-nautilus-availability', () => isNautilusAvailable()); ipcMain.handle('check-internet-connection', async () => { return new Promise((resolve) => { From 5a425934f92500e09ad67f45418c0b362e50dd11 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Wed, 10 Jun 2026 15:33:50 -0500 Subject: [PATCH 2/4] refactor: streamline imports and ensure newline consistency in Nautilus extension files --- src/apps/renderer/localize/locales/en.json | 2 +- src/apps/renderer/localize/locales/es.json | 2 +- src/apps/renderer/localize/locales/fr.json | 2 +- src/backend/features/nautilus-extension/install.ts | 7 +------ .../features/nautilus-extension/is-nautilus-available.ts | 2 +- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/apps/renderer/localize/locales/en.json b/src/apps/renderer/localize/locales/en.json index e82082df6c..f72114d07a 100644 --- a/src/apps/renderer/localize/locales/en.json +++ b/src/apps/renderer/localize/locales/en.json @@ -438,4 +438,4 @@ "ctaUpgrade": "Upgrade plan", "plan": "{{planName}} -> up to {{planMaxFileSize}}" } -} \ No newline at end of file +} diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json index 06b5a6b71c..ef3f976017 100644 --- a/src/apps/renderer/localize/locales/es.json +++ b/src/apps/renderer/localize/locales/es.json @@ -438,4 +438,4 @@ "ctaUpgrade": "Mejorar plan", "plan": "{{planName}} -> hasta {{planMaxFileSize}}" } -} \ No newline at end of file +} diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json index fbaad9639a..38400e83de 100644 --- a/src/apps/renderer/localize/locales/fr.json +++ b/src/apps/renderer/localize/locales/fr.json @@ -438,4 +438,4 @@ "ctaUpgrade": "Mettre à jour le plan", "plan": "{{planName}} -> jusqu'à {{planMaxFileSize}}" } -} \ No newline at end of file +} diff --git a/src/backend/features/nautilus-extension/install.ts b/src/backend/features/nautilus-extension/install.ts index 528c2d7f6b..06eeb7ed97 100644 --- a/src/backend/features/nautilus-extension/install.ts +++ b/src/backend/features/nautilus-extension/install.ts @@ -1,10 +1,5 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { - copyNautilusExtensionFile, - deleteNautilusExtensionFile, - isInstalled, - reloadNautilus, -} from './service'; +import { copyNautilusExtensionFile, deleteNautilusExtensionFile, isInstalled, reloadNautilus } from './service'; import { isNautilusAvailable } from './is-nautilus-available'; import configStore from '../../../apps/main/config'; diff --git a/src/backend/features/nautilus-extension/is-nautilus-available.ts b/src/backend/features/nautilus-extension/is-nautilus-available.ts index 33596cce00..964dfae875 100644 --- a/src/backend/features/nautilus-extension/is-nautilus-available.ts +++ b/src/backend/features/nautilus-extension/is-nautilus-available.ts @@ -39,4 +39,4 @@ async function hasNautilusBinary() { function desktopEntryUsesNautilus({ desktopEntry }: { desktopEntry: string }) { return desktopEntry.includes('nautilus.desktop'); -} \ No newline at end of file +} From 583b3b32e44a7fb894437d36a5971741c5db9713 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Wed, 10 Jun 2026 23:04:40 -0500 Subject: [PATCH 3/4] fix: correct import path for isNautilusAvailable in IPC handlers --- .../features/nautilus-extension/is-nautilus-available.test.ts | 4 ++-- src/core/bootstrap/register-main-ipc-handlers.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/features/nautilus-extension/is-nautilus-available.test.ts b/src/backend/features/nautilus-extension/is-nautilus-available.test.ts index 43a4f2e228..278a9ac536 100644 --- a/src/backend/features/nautilus-extension/is-nautilus-available.test.ts +++ b/src/backend/features/nautilus-extension/is-nautilus-available.test.ts @@ -17,12 +17,12 @@ type ExecAsyncResult = { stderr: string; }; -type Pops = { +type Props = { desktopEntry: string; hasNautilusBinary: boolean; }; -function mockExecWith({ desktopEntry, hasNautilusBinary }: Pops) { +function mockExecWith({ desktopEntry, hasNautilusBinary }: Props) { execAsyncMock.mockImplementation(async (command: string) => { if (command === 'xdg-mime query default inode/directory') { return { diff --git a/src/core/bootstrap/register-main-ipc-handlers.ts b/src/core/bootstrap/register-main-ipc-handlers.ts index 4761e28a9e..9469cf23de 100644 --- a/src/core/bootstrap/register-main-ipc-handlers.ts +++ b/src/core/bootstrap/register-main-ipc-handlers.ts @@ -1,6 +1,6 @@ import dns from 'node:dns'; import { ipcMain } from 'electron'; -import { isNautilusAvailable } from '../../apps/main/nautilus-extension/is-nautilus-available'; +import { isNautilusAvailable } from '../../backend/features/nautilus-extension/is-nautilus-available'; import { getPendingUpdateInfo } from './bootstrap-runtime-state'; export function registerMainIpcHandlers() { From 284e9215a5b5a423c46c59e9fdc491d6f5708a09 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Wed, 10 Jun 2026 23:14:58 -0500 Subject: [PATCH 4/4] feat(tests): add mock for isNautilusAvailable and test installation skip logic --- .../nautilus-extension/install.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/backend/features/nautilus-extension/install.test.ts b/src/backend/features/nautilus-extension/install.test.ts index 1af91af018..e5b90a457f 100644 --- a/src/backend/features/nautilus-extension/install.test.ts +++ b/src/backend/features/nautilus-extension/install.test.ts @@ -1,11 +1,13 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; import configStore from '../../../apps/main/config'; +import * as isNautilusAvailableModule from './is-nautilus-available'; import * as serviceModule from './service'; import { LATEST_NAUTILUS_EXTENSION_VERSION } from './version'; import { call, calls, partialSpyOn } from 'tests/vitest/utils.helper'; import { installNautilusExtension } from './install'; describe('install', () => { + const isNautilusAvailableMock = partialSpyOn(isNautilusAvailableModule, 'isNautilusAvailable'); const isInstalledMock = partialSpyOn(serviceModule, 'isInstalled'); const copyNautilusExtensionFileMock = partialSpyOn(serviceModule, 'copyNautilusExtensionFile'); const deleteNautilusExtensionFileMock = partialSpyOn(serviceModule, 'deleteNautilusExtensionFile'); @@ -16,9 +18,9 @@ describe('install', () => { const loggerErrorMock = partialSpyOn(logger, 'error'); beforeEach(() => { - vi.clearAllMocks(); process.env.NODE_ENV = 'development'; + isNautilusAvailableMock.mockResolvedValue(true); isInstalledMock.mockResolvedValue(false); copyNautilusExtensionFileMock.mockResolvedValue(undefined); deleteNautilusExtensionFileMock.mockResolvedValue(undefined); @@ -26,6 +28,22 @@ describe('install', () => { configGetMock.mockReturnValue(0); }); + it('should skip installation when nautilus is unavailable', async () => { + // Given + isNautilusAvailableMock.mockResolvedValueOnce(false); + + // When + await installNautilusExtension(); + + // Then + call(isNautilusAvailableMock).toStrictEqual([]); + calls(isInstalledMock).toHaveLength(0); + calls(copyNautilusExtensionFileMock).toHaveLength(0); + calls(deleteNautilusExtensionFileMock).toHaveLength(0); + calls(configSetMock).toHaveLength(0); + calls(reloadNautilusMock).toHaveLength(0); + }); + it('should install and reload when extension is not installed', async () => { // Given isInstalledMock.mockResolvedValueOnce(false);