diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 260428a77..5b32bea61 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 2a5774606..b841a6867 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 170be45c3..45947da44 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 72b99c229..f72114d07 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" diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json index b5300ac71..ef3f97601 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" diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json index 7cccf062a..38400e83d 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" 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 000000000..fa5488be1 --- /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 000000000..e0cc384ab --- /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 cc1f10953..0c3cd952f 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.test.ts b/src/backend/features/nautilus-extension/install.test.ts index 1af91af01..e5b90a457 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); diff --git a/src/backend/features/nautilus-extension/install.ts b/src/backend/features/nautilus-extension/install.ts index 448d6f5db..06eeb7ed9 100644 --- a/src/backend/features/nautilus-extension/install.ts +++ b/src/backend/features/nautilus-extension/install.ts @@ -1,5 +1,6 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; 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 +27,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 000000000..278a9ac53 --- /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 Props = { + desktopEntry: string; + hasNautilusBinary: boolean; +}; + +function mockExecWith({ desktopEntry, hasNautilusBinary }: Props) { + 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 000000000..964dfae87 --- /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'); +} diff --git a/src/core/bootstrap/register-main-ipc-handlers.ts b/src/core/bootstrap/register-main-ipc-handlers.ts index ae8c8b06d..9469cf23d 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 '../../backend/features/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) => {