Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/apps/main/interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export interface IElectronAPI {
onBackupProgress(func: (value: number) => void): () => void;
startRemoteSync(): Promise<void>;
getUpdateStatus(): Promise<{ version: string } | null>;
getNautilusAvailability(): Promise<boolean>;
onUpdateAvailable(callback: (info: { version: string }) => void): () => void;
getRemoteSyncStatus(): Promise<import('./remote-sync/helpers').RemoteSyncStatus>;
onRemoteSyncStatusChange(callback: (status: import('./remote-sync/helpers').RemoteSyncStatus) => void): () => void;
Expand Down
1 change: 1 addition & 0 deletions src/apps/main/preload.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ declare interface Window {
getDiskSpace: () => Promise<number>;
};
getUpdateStatus(): Promise<{ version: string } | null>;
getNautilusAvailability(): Promise<boolean>;
onUpdateAvailable(callback: (info: { version: string }) => void): () => void;
};
}
3 changes: 3 additions & 0 deletions src/apps/main/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/apps/renderer/localize/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/apps/renderer/localize/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/apps/renderer/localize/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<NautilusUnavailable />);

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(<NautilusUnavailable />);

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(<NautilusUnavailable />);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 11 in src/apps/renderer/pages/Widget/InfoBanners/Banners/NautilusUnavailable.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-desktop-linux&issues=AZ6zQFNhZKBa2Ec_glc5&open=AZ6zQFNhZKBa2Ec_glc5&pullRequest=387
.getNautilusAvailability()
.then((isAvailable) => {
setIsNautilusAvailable(isAvailable);
})
.catch(() => {
setIsNautilusAvailable(true);
});
}, []);

if (isNautilusAvailable || dismissed) {
return <></>;
}

return (
<div className="mx-1 mt-3 flex items-center gap-2.5 rounded-xl border-2 border-[#FFF4CC] bg-[#FFF9E5] px-4 py-2 dark:border-[#7F6B19] dark:bg-[#4C400F]">
<Warning size={22} weight="fill" className="shrink-0 text-yellow-dark" />
<div className="flex flex-1 flex-col">
<p className="text-xs text-gray-80">{translate('widget.banners.nautilus-unavailable.body')}</p>
</div>
<button
onClick={() => setDismissed(true)}
className="ml-2 shrink-0 rounded-md p-1 text-gray-50 transition-colors hover:bg-black/5 hover:text-gray-80"
aria-label="Dismiss">
<X size={18} />
</button>
</div>
);
}
2 changes: 2 additions & 0 deletions src/apps/renderer/pages/Widget/InfoBanners/InfoBanners.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { DiscoverBackups } from './Banners/DiscoverBackups';
import { NautilusUnavailable } from './Banners/NautilusUnavailable';
import { UpdateAvailable } from './Banners/UpdateAvailable';

export function InfoBanners() {
return (
<>
<NautilusUnavailable />
<UpdateAvailable />
<DiscoverBackups />
</>
Expand Down
20 changes: 19 additions & 1 deletion src/backend/features/nautilus-extension/install.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -16,16 +18,32 @@ 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);
reloadNautilusMock.mockResolvedValue(undefined);
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);
Expand Down
5 changes: 5 additions & 0 deletions src/backend/features/nautilus-extension/install.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,6 +27,10 @@ async function install(): Promise<void> {

export async function installNautilusExtension() {
try {
const canInstall = await isNautilusAvailable();

if (!canInstall) return;

const installed = await isInstalled();
const hasLatestsVersion = isUpToDate();

Expand Down
103 changes: 103 additions & 0 deletions src/backend/features/nautilus-extension/is-nautilus-available.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions src/backend/features/nautilus-extension/is-nautilus-available.ts
Original file line number Diff line number Diff line change
@@ -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');
}
2 changes: 2 additions & 0 deletions src/core/bootstrap/register-main-ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
Loading