diff --git a/.eslintrc.js b/.eslintrc.js index 92f3a8cbe4..4dbbfddd41 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,7 +13,6 @@ module.exports = { }, ], rules: { - 'no-await-in-loop': 'warn', '@typescript-eslint/no-use-before-define': ['warn', { functions: false, classes: true, variables: true }], 'array-callback-return': 'warn', 'max-len': [ diff --git a/.github/workflows/file-manager-extension-smoke.yml b/.github/workflows/file-manager-extension-smoke.yml new file mode 100644 index 0000000000..8bfacf12d8 --- /dev/null +++ b/.github/workflows/file-manager-extension-smoke.yml @@ -0,0 +1,64 @@ +name: File Manager Extension Smoke + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + smoke-file-manager-extension: + name: "🧪 Smoke ${{ matrix.fileManager }} extension" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + fileManager: [nautilus, nemo, dolphin] + + container: + image: ubuntu:22.04 + + steps: + - name: Install base dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl git python3 unzip xdg-utils + + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install file manager for matrix target + run: | + case "${{ matrix.fileManager }}" in + nautilus) + DEBIAN_FRONTEND=noninteractive apt-get install -y nautilus + ;; + nemo) + DEBIAN_FRONTEND=noninteractive apt-get install -y nemo + ;; + dolphin) + DEBIAN_FRONTEND=noninteractive apt-get install -y dolphin + ;; + *) + echo "Unsupported file manager: ${{ matrix.fileManager }}" + exit 1 + ;; + esac + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Ensure Electron install + run: node ./.erb/scripts/ensure-electron-install.cjs + + - name: Run extension smoke test + env: + EXPECTED_FILE_MANAGER: ${{ matrix.fileManager }} + HOME: ${{ github.workspace }}/.tmp/internxt-home + run: | + mkdir -p "$HOME" + npm run smoke:file-manager-extension diff --git a/README.md b/README.md index f4d8d8aec1..3d55ff2c74 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,11 @@ ## Compatibility -As of right now, Internxt Drive Desktop for Linux is only compatible with Ubuntu and Debian with the File explorer **Nautilus** (The default file explorer for Gnome). +Internxt Drive Desktop for Linux is currently tested and supported on Ubuntu and Debian with the file managers **Nautilus**, **Nemo**, and **Dolphin**. -We cannot guarantee that the app will work properly on other Linux distributions or with other file explorers as our development and testing efforts are focused on ensuring the best experience for Ubuntu and Debian users. +The application is also available through the **.deb** and **.rpm** packages for these distributions. + +We cannot guarantee full compatibility on other Linux distributions or with unsupported file managers, although the app may still work in some environments. ## Installation @@ -20,6 +22,66 @@ Download and install the `.deb` package for full compatibility: sudo dpkg -i internxt_2.6.0_amd64.deb ``` +## Prerequisites for KDE based distros + +### KDE Wallet Configuration Guide + +Our application requires the KDE key manager to be properly configured. Depending on your security needs, you can choose between two methods: + +* **Method 1 (Recommended / Easy):** Uses standard symmetric encryption with a master password. It is fast, requires no additional software, and supports **automatic unlocking when you log in**. +* **Method 2 (Advanced / GPG):** Uses an OpenPGP key pair via Kleopatra for higher security, though it requires manual entry of your passphrase or PIN upon logging in. + +> **Why Kleopatra?** It is the official KDE key manager, offering native integration with KDE Wallet, fewer permission conflicts, and a user-friendly setup wizard compared to generic GPG tools. +> +> For reference, Electron's secure storage API is documented here: [safe-storage](https://www.electronjs.org/docs/latest/api/safe-storage). + +--- + +### Method 1: Standard Setup (Easy & Recommended) + +This is the simplest way to set up KDE Wallet and allows seamless automatic unlocking upon system login. + +#### Step 1: Open KDE Wallet Settings +1. Open **System Settings**. +2. Navigate to **KDE Wallet** (or search for *Wallet* in the search bar). +3. Ensure **Enable the KDE wallet subsystem** is checked. + +#### Step 2: Create a New Wallet +1. Under **Automatic Wallet Selection**, click **Create New Wallet...** +2. Enter a name for your wallet (e.g., `kdewallet`). +3. Select **Blowfish encryption** (standard password) and click **Next**. +4. Enter and confirm your **Master Password**. + > **Note:** If you set this password to match your Linux user login password, the wallet will unlock automatically when you sign in! +5. Click **Finish**. + +--- + +### Method 2: GPG Key Setup (Advanced) + +Use this method if you prefer asymmetric GPG encryption managed via external key managers. + +#### Step 1: Install Kleopatra +Install **Kleopatra**, which will be used to generate your GPG encryption key: + +```bash +sudo apt update && sudo apt install kleopatra +``` + +#### Step 2: Generate a GPG Key Pair +1. Open **Kleopatra** and click **New Key Pair** (or **File > New Key Pair**). +2. Select **Create a personal OpenPGP key pair**. +3. Enter your **Name** and **Email Address**. +4. Click **Create** (or **Finish**) to complete the setup. + +### Step 3: Configure KDE Wallet for GPG +1. Open **System Settings** and search for **KDE Wallet**. +2. Under **Automatic Wallet Selection**, click **Create New Wallet...** +3. Select **Use GPG encryption for added security** and click **Next**. +4. Choose the GPG key you created earlier in Kleopatra and click **Finish**. + +--- + + ### AppImage Alternatively, you can use the AppImage format: diff --git a/assets/dolphin/internxt-dolphin-actions.sh b/assets/dolphin/internxt-dolphin-actions.sh new file mode 100644 index 0000000000..71c8decdd3 --- /dev/null +++ b/assets/dolphin/internxt-dolphin-actions.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -u + +BASE_URL="http://localhost:4567/hydration" +ROOT_FOLDER="$HOME/Internxt Drive" + +function encode_relative_path() { + python3 - "$1" "$ROOT_FOLDER" <<'PY' +import base64 +import os +import sys + +file_path = os.path.realpath(sys.argv[1]) +root_folder = os.path.realpath(sys.argv[2]) + +if not file_path.startswith(root_folder): + print("") + sys.exit(0) + +relative_path = file_path[len(root_folder):] +if relative_path == "": + relative_path = "/" + +print(base64.b64encode(relative_path.encode("utf-8")).decode("utf-8")) +PY +} + +function copy_to_clipboard() { + local value="$1" + + if command -v wl-copy >/dev/null 2>&1; then + printf '%s' "$value" | wl-copy + return + fi + + if command -v xclip >/dev/null 2>&1; then + printf '%s' "$value" | xclip -selection clipboard + return + fi + + if command -v xsel >/dev/null 2>&1; then + printf '%s' "$value" | xsel --clipboard --input + return + fi +} + +function copy_link() { + local file_path="$1" + local encoded + encoded="$(encode_relative_path "$file_path")" + + if [ -z "$encoded" ]; then + exit 0 + fi + + local response + response="$(curl -sS -X POST "$BASE_URL/copy-link/$encoded" 2>/dev/null || true)" + if [ -z "$response" ]; then + exit 0 + fi + + local link + link="$(python3 - "$response" <<'PY' +import json +import sys + +response = sys.argv[1] +try: + data = json.loads(response) +except json.JSONDecodeError: + print("") + sys.exit(0) + +print(data.get("link", "")) +PY +)" + + if [ -n "$link" ]; then + copy_to_clipboard "$link" + fi +} + +if [ "$#" -lt 2 ]; then + exit 0 +fi + +action="$1" +shift + +if [ "$action" = "copy-link" ]; then + copy_link "$1" + exit 0 +fi diff --git a/assets/dolphin/internxt-virtual-drive.desktop b/assets/dolphin/internxt-virtual-drive.desktop new file mode 100644 index 0000000000..e17b792eda --- /dev/null +++ b/assets/dolphin/internxt-virtual-drive.desktop @@ -0,0 +1,15 @@ +[Desktop Entry] +Type=Service +Name=Internxt Drive Actions +MimeType=all/allfiles;inode/directory; +ServiceTypes=KonqPopupMenu/Plugin +X-KDE-ServiceTypes=KonqPopupMenu/Plugin +Actions=InternxtCopyLink; +X-KDE-Submenu=Internxt Drive +X-KDE-Priority=TopLevel +X-KDE-StartupNotify=false + +[Desktop Action InternxtCopyLink] +Name=Copy Internxt Link +Icon=insert-link +Exec=/usr/bin/env bash {{HOME}}/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh copy-link %f diff --git a/package.json b/package.json index cf3ea00d74..a9a03a95c3 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:renderer": "vitest --config vitest.config.renderer.ts", "test:renderer:coverage": "vitest --config vitest.config.renderer.ts --coverage", "test:coverage": "concurrently \"npm:test:main:coverage\" \"npm:test:renderer:coverage\"", + "smoke:file-manager-extension": "NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true ts-node -r ./tests/smoke/file-manager-extension/smoke-electron-mock.cjs src/backend/features/file-manager-extension/file-manager-extension-smoke.ts", "coverage:merge": "lcov-result-merger 'coverage/*/lcov.info' 'coverage/lcov.info'", "type-check": "./scripts/tsc-max-errors.sh", "prepare": "husky install", @@ -75,7 +76,7 @@ "category": "Development" }, "deb": { - "depends": [ + "recommends": [ "python3-nautilus" ] }, @@ -253,4 +254,4 @@ "node": ">=24.0.0 <25.0.0", "npm": ">=10.0.0 <11.0.0" } -} +} \ No newline at end of file diff --git a/src/backend/features/file-manager-extension/constants.ts b/src/backend/features/file-manager-extension/constants.ts new file mode 100644 index 0000000000..ac45d4ee25 --- /dev/null +++ b/src/backend/features/file-manager-extension/constants.ts @@ -0,0 +1,11 @@ +const supportedFileManagers = ['nautilus', 'nemo', 'dolphin', null] as const; +export type SupportedFileManager = (typeof supportedFileManagers)[number]; + +export const NAUTILUS_EXTENSION_FILENAME = 'internxt-virtual-drive.py'; +export const NEMO_EXTENSION_FILENAME = 'internxt-virtual-drive.py'; +export const DOLPHIN_MENU_FILENAME = 'internxt-virtual-drive.desktop'; +export const DOLPHIN_HELPER_FILENAME = 'internxt-dolphin-actions.sh'; + +export function isSupportedFileManager(value: SupportedFileManager): value is SupportedFileManager { + return supportedFileManagers.includes(value); +} diff --git a/src/backend/features/file-manager-extension/detect-available.test.ts b/src/backend/features/file-manager-extension/detect-available.test.ts index 983f7e4959..894936aa51 100644 --- a/src/backend/features/file-manager-extension/detect-available.test.ts +++ b/src/backend/features/file-manager-extension/detect-available.test.ts @@ -1,4 +1,4 @@ -import { detectAvailableFileManager, isNautilusAvailable, isNemoAvailable } from './detect-available'; +import { detectAvailableFileManager, isNautilusAvailable } from './detect-available'; const { execAsyncMock } = vi.hoisted(() => ({ execAsyncMock: vi.fn(), @@ -21,9 +21,10 @@ type Props = { desktopEntry?: string; hasNautilus?: boolean; hasNemo?: boolean; + hasDolphin?: boolean; }; -function mockExecWith({ desktopEntry, hasNautilus = false, hasNemo = false }: Props) { +function mockExecWith({ desktopEntry, hasNautilus = false, hasNemo = false, hasDolphin = false }: Props) { execAsyncMock.mockImplementation(async (command: string) => { if (command === 'xdg-mime query default inode/directory') { if (!desktopEntry) throw new Error('not found'); @@ -55,6 +56,17 @@ function mockExecWith({ desktopEntry, hasNautilus = false, hasNemo = false }: Pr } } + if (command === 'command -v dolphin') { + if (hasDolphin) { + return { + stdout: '/usr/bin/dolphin\n', + stderr: '', + } as ExecAsyncResult; + } else { + throw new Error('dolphin not found'); + } + } + throw new Error(`Unexpected command: ${command}`); }); } @@ -85,6 +97,16 @@ describe('detect-available', () => { expect(result).toBe('nemo'); }); + it('should detect dolphin when it is the default directory manager', async () => { + mockExecWith({ + desktopEntry: 'org.kde.dolphin.desktop', + hasDolphin: true, + }); + + const result = await detectAvailableFileManager(); + expect(result).toBe('dolphin'); + }); + it('should fallback to nemo if only nemo binary is available', async () => { mockExecWith({ hasNemo: true, @@ -94,6 +116,15 @@ describe('detect-available', () => { expect(result).toBe('nemo'); }); + it('should fallback to dolphin if only dolphin binary is available', async () => { + mockExecWith({ + hasDolphin: true, + }); + + const result = await detectAvailableFileManager(); + expect(result).toBe('dolphin'); + }); + it('should return null when no file manager is available', async () => { mockExecWith({}); @@ -120,23 +151,4 @@ describe('detect-available', () => { expect(result).toBe(false); }); }); - - describe('isNemoAvailable', () => { - it('should return true when nemo is available', async () => { - mockExecWith({ - desktopEntry: 'nemo.desktop', - hasNemo: true, - }); - - const result = await isNemoAvailable(); - expect(result).toBe(true); - }); - - it('should return false when nemo is not available', async () => { - mockExecWith({}); - - const result = await isNemoAvailable(); - expect(result).toBe(false); - }); - }); }); diff --git a/src/backend/features/file-manager-extension/detect-available.ts b/src/backend/features/file-manager-extension/detect-available.ts index 4d5cbf7f39..9d1f9ab0cb 100644 --- a/src/backend/features/file-manager-extension/detect-available.ts +++ b/src/backend/features/file-manager-extension/detect-available.ts @@ -1,36 +1,37 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; +import { type SupportedFileManager } from './constants'; const execAsync = promisify(exec); -export type FileManagerType = 'nautilus' | 'nemo' | null; +type FileManagerCandidate = { + type: SupportedFileManager; + desktopEntry: string; + hasBinary: () => Promise; +}; -export async function detectAvailableFileManager(): Promise { +const FILE_MANAGER_CANDIDATES: FileManagerCandidate[] = [ + { type: 'dolphin', desktopEntry: 'dolphin.desktop', hasBinary: hasDolphinBinary }, + { type: 'nemo', desktopEntry: 'nemo.desktop', hasBinary: hasNemoBinary }, + { type: 'nautilus', desktopEntry: 'nautilus.desktop', hasBinary: hasNautilusBinary }, +]; + +export async function detectAvailableFileManager(): Promise { const desktopEntry = await getDefaultDirectoryDesktopEntry(); if (desktopEntry) { - if (desktopEntry.includes('nemo.desktop')) { - if (await hasNemoBinary()) { - return 'nemo'; - } - } - - if (desktopEntry.includes('nautilus.desktop')) { - if (await hasNautilusBinary()) { - return 'nautilus'; + for (const candidate of FILE_MANAGER_CANDIDATES) { + if (desktopEntry.includes(candidate.desktopEntry) && (await candidate.hasBinary())) { + return candidate.type; } } } // Fallback: check for available binaries - const hasNemo = await hasNemoBinary(); - if (hasNemo) { - return 'nemo'; - } - - const hasNautilus = await hasNautilusBinary(); - if (hasNautilus) { - return 'nautilus'; + for (const candidate of FILE_MANAGER_CANDIDATES) { + if (await candidate.hasBinary()) { + return candidate.type; + } } return null; @@ -40,10 +41,6 @@ export async function isNautilusAvailable(): Promise { return (await detectAvailableFileManager()) === 'nautilus'; } -export async function isNemoAvailable(): Promise { - return (await detectAvailableFileManager()) === 'nemo'; -} - async function getDefaultDirectoryDesktopEntry(): Promise { try { const { stdout } = await execAsync('xdg-mime query default inode/directory'); @@ -70,3 +67,12 @@ async function hasNemoBinary(): Promise { return false; } } + +async function hasDolphinBinary(): Promise { + try { + await execAsync('command -v dolphin'); + return true; + } catch { + return false; + } +} diff --git a/src/backend/features/file-manager-extension/file-manager-extension-smoke.ts b/src/backend/features/file-manager-extension/file-manager-extension-smoke.ts new file mode 100644 index 0000000000..a11b0a1780 --- /dev/null +++ b/src/backend/features/file-manager-extension/file-manager-extension-smoke.ts @@ -0,0 +1,154 @@ +/* eslint-disable no-console */ +import { access, readFile, stat } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { copyExtensionFile, deleteExtensionFile, isInstalled } from './service'; +import { detectAvailableFileManager } from './detect-available'; +import { isSupportedFileManager, type SupportedFileManager } from './constants'; + +type AssertProps = { + condition: boolean; + message: string; +}; + +function assertOrThrow({ condition, message }: AssertProps) { + if (!condition) { + throw new Error(message); + } +} + +function expectedManagerFromEnv() { + const raw = process.env.EXPECTED_FILE_MANAGER?.trim().toLowerCase() as SupportedFileManager; + + if (!raw) return null; + + if (isSupportedFileManager(raw)) return raw; + + throw new Error(`Invalid EXPECTED_FILE_MANAGER: ${raw}`); +} + +function getExpectedPaths({ manager }: { manager: SupportedFileManager }) { + const home = homedir(); + + if (manager === 'nautilus') { + return [join(home, '.local/share/nautilus-python/extensions/internxt-virtual-drive.py')]; + } + + if (manager === 'nemo') { + return [join(home, '.local/share/nemo-python/extensions/internxt-virtual-drive.py')]; + } + + return [ + join(home, '.local/share/kio/servicemenus/internxt-virtual-drive.desktop'), + join(home, '.local/share/kservices5/ServiceMenus/internxt-virtual-drive.desktop'), + join(home, '.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh'), + ]; +} + +async function assertPathExists({ filePath }: { filePath: string }) { + await access(filePath, fsConstants.F_OK); +} + +async function assertExecutable({ filePath }: { filePath: string }) { + const fileStat = await stat(filePath); + assertOrThrow({ + condition: (fileStat.mode & 0o111) !== 0, + message: `Expected executable permissions for ${filePath}`, + }); +} + +async function assertPathRemoved({ filePath }: { filePath: string }) { + let exists = true; + + try { + await access(filePath, fsConstants.F_OK); + } catch { + exists = false; + } + + assertOrThrow({ + condition: !exists, + message: `Expected file to be removed: ${filePath}`, + }); +} + +async function assertNoHomeTemplateToken({ filePath }: { filePath: string }) { + const content = await readFile(filePath, 'utf8'); + assertOrThrow({ + condition: !content.includes('{{HOME}}'), + message: `Template token {{HOME}} was not replaced in ${filePath}`, + }); +} + +async function run() { + const detected = await detectAvailableFileManager(); + assertOrThrow({ + condition: detected !== null, + message: 'No supported file manager detected in smoke environment', + }); + + const expectedFromEnv = expectedManagerFromEnv(); + if (expectedFromEnv) { + assertOrThrow({ + condition: detected === expectedFromEnv, + message: `Detected ${detected} but expected ${expectedFromEnv}`, + }); + } + + if (detected === null) { + throw new Error('No supported file manager detected in smoke environment'); + } + + const manager = detected; + const expectedPaths = getExpectedPaths({ manager }); + + await deleteExtensionFile(); + + const installedBefore = await isInstalled(); + assertOrThrow({ + condition: !installedBefore, + message: 'Extension should not be installed after pre-cleanup', + }); + + await copyExtensionFile(); + + const installedAfterCopy = await isInstalled(); + assertOrThrow({ + condition: installedAfterCopy, + message: 'Extension should be installed after copyExtensionFile', + }); + + for (const filePath of expectedPaths) { + await assertPathExists({ filePath }); + } + + if (manager === 'dolphin') { + await assertNoHomeTemplateToken({ filePath: expectedPaths[0] }); + await assertNoHomeTemplateToken({ filePath: expectedPaths[1] }); + + for (const filePath of expectedPaths) { + await assertExecutable({ filePath }); + } + } + + await deleteExtensionFile(); + + const installedAfterDelete = await isInstalled(); + assertOrThrow({ + condition: !installedAfterDelete, + message: 'Extension should not be installed after deleteExtensionFile', + }); + + for (const filePath of expectedPaths) { + await assertPathRemoved({ filePath }); + } + + console.log(`File manager extension smoke passed for: ${manager}`); +} + +run().catch((error: unknown) => { + console.error('File manager extension smoke failed'); + console.error(error); + process.exit(1); +}); diff --git a/src/backend/features/file-manager-extension/install.test.ts b/src/backend/features/file-manager-extension/install.test.ts index f3c3470a59..8adacd80bf 100644 --- a/src/backend/features/file-manager-extension/install.test.ts +++ b/src/backend/features/file-manager-extension/install.test.ts @@ -123,6 +123,22 @@ describe('install', () => { expect.arrayContaining([expect.objectContaining({ msg: expect.stringContaining('nemo') })]), ); }); + + it('should detect dolphin when available', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + getFileManagerTypeMock.mockResolvedValueOnce('dolphin'); + isInstalledMock.mockResolvedValueOnce(false); + + // When + await installFileManagerExtension(); + + // Then + call(configSetMock).toStrictEqual(['fileManagerExtensionVersion', LATEST_EXTENSION_VERSION]); + calls(loggerDebugMock).toMatchObject( + expect.arrayContaining([expect.objectContaining({ msg: expect.stringContaining('dolphin') })]), + ); + }); }); describe('uninstallFileManagerExtension', () => { diff --git a/src/backend/features/file-manager-extension/install.ts b/src/backend/features/file-manager-extension/install.ts index f212a71196..1aa5de644d 100644 --- a/src/backend/features/file-manager-extension/install.ts +++ b/src/backend/features/file-manager-extension/install.ts @@ -33,7 +33,7 @@ export async function installFileManagerExtension() { if (!fileManager) { logger.debug({ - msg: '[FILE_MANAGER_EXTENSION] No compatible file manager found (Nautilus or Nemo)', + msg: '[FILE_MANAGER_EXTENSION] No compatible file manager found (Nautilus, Nemo or Dolphin)', }); return; } diff --git a/src/backend/features/file-manager-extension/service.test.ts b/src/backend/features/file-manager-extension/service.test.ts index fd9e83db81..29576899ef 100644 --- a/src/backend/features/file-manager-extension/service.test.ts +++ b/src/backend/features/file-manager-extension/service.test.ts @@ -1,7 +1,9 @@ import * as detectModule from './detect-available'; import * as fileExistsModule from '../../../apps/shared/fs/fileExists'; -import { getFileManagerType, isInstalled, reloadFileManager } from './service'; +import fs from 'node:fs/promises'; +import { copyExtensionFile, deleteExtensionFile, getFileManagerType, isInstalled, reloadFileManager } from './service'; import { partialSpyOn } from 'tests/vitest/utils.helper'; +import { PATHS } from '../../../core/electron/paths'; const { execMock } = vi.hoisted(() => ({ execMock: vi.fn(), @@ -17,15 +19,20 @@ vi.mock('node:fs/promises', () => ({ link: vi.fn(), cp: vi.fn(), rm: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + chmod: vi.fn(), }, })); describe('service', () => { const detectAvailableFileManagerMock = partialSpyOn(detectModule, 'detectAvailableFileManager'); const doesFileExistMock = partialSpyOn(fileExistsModule, 'doesFileExist'); + const fsMock = vi.mocked(fs); beforeEach(() => { vi.clearAllMocks(); + process.env.NODE_ENV = 'development'; detectAvailableFileManagerMock.mockResolvedValue('nautilus'); doesFileExistMock.mockResolvedValue(false); }); @@ -53,6 +60,17 @@ describe('service', () => { expect(result).toBe('nemo'); }); + it('should return dolphin when available', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + + // When + const result = await getFileManagerType(); + + // Then + expect(result).toBe('dolphin'); + }); + it('should return null when no file manager is available', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce(null); @@ -112,6 +130,20 @@ describe('service', () => { // Then expect(result).toBe(true); }); + + it('should return true when dolphin extension assets exist', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + doesFileExistMock.mockResolvedValueOnce(true); + doesFileExistMock.mockResolvedValueOnce(true); + doesFileExistMock.mockResolvedValueOnce(true); + + // When + const result = await isInstalled(); + + // Then + expect(result).toBe(true); + }); }); describe('reloadFileManager', () => { @@ -162,6 +194,58 @@ describe('service', () => { expect(execMock).toHaveBeenCalled(); }); + it('should execute dolphin reload command when dolphin is available', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + execMock.mockImplementation((cmd, optionsOrCallback, callback) => { + expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); + + const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + cb?.(null, '', ''); + }); + + // When + await reloadFileManager(); + + // Then + expect(execMock).toHaveBeenCalled(); + }); + + it('should ignore dolphin stderr when dolphin is not running', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + execMock.mockImplementation((cmd, optionsOrCallback, callback) => { + expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); + + const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + cb?.( + null, + '', + 'Application dolphin could not be found using service org.kde.dolphin and path /MainApplication.', + ); + }); + + // When + await reloadFileManager(); + + // Then + expect(execMock).toHaveBeenCalled(); + }); + + it('should reject unexpected dolphin stderr', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + execMock.mockImplementation((cmd, optionsOrCallback, callback) => { + expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); + + const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + cb?.(null, '', 'unexpected dolphin stderr'); + }); + + // When / Then + await expect(reloadFileManager()).rejects.toThrow('unexpected dolphin stderr'); + }); + it('should pass a timeout to the reload command', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); @@ -210,4 +294,110 @@ describe('service', () => { expect(true).toBe(true); }); }); + + describe('copyExtensionFile', () => { + it('should do nothing when no file manager is available', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce(null); + + // When + await copyExtensionFile(); + + // Then + expect(fsMock.mkdir).not.toHaveBeenCalled(); + expect(fsMock.link).not.toHaveBeenCalled(); + expect(fsMock.cp).not.toHaveBeenCalled(); + }); + + it('should skip copying when assets are already installed', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); + doesFileExistMock.mockResolvedValue(true); + + // When + await copyExtensionFile(); + + // Then + expect(fsMock.mkdir).not.toHaveBeenCalled(); + expect(fsMock.link).not.toHaveBeenCalled(); + expect(fsMock.cp).not.toHaveBeenCalled(); + }); + + it('should use fs.link for non-template assets in development mode', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); + doesFileExistMock.mockResolvedValue(false); + + // When + await copyExtensionFile(); + + // Then + expect(fsMock.link).toHaveBeenCalled(); + expect(fsMock.cp).not.toHaveBeenCalled(); + }); + + it('should use fs.cp for non-template assets in production mode', async () => { + // Given + process.env.NODE_ENV = 'production'; + detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); + doesFileExistMock.mockResolvedValue(false); + + // When + await copyExtensionFile(); + + // Then + expect(fsMock.cp).toHaveBeenCalled(); + expect(fsMock.link).not.toHaveBeenCalled(); + }); + + it('should template dolphin service menu with the shared home path', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + doesFileExistMock.mockResolvedValue(false); + fsMock.readFile.mockResolvedValue( + 'Exec=/usr/bin/env bash "{{HOME}}/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh" copy-link %f', + ); + + // When + await copyExtensionFile(); + + // Then + expect(fsMock.writeFile).toHaveBeenCalledWith( + expect.stringContaining('/.local/share/kio/servicemenus/internxt-virtual-drive.desktop'), + expect.stringContaining( + `${PATHS.HOME_FOLDER_PATH}/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh`, + ), + 'utf8', + ); + expect(fsMock.chmod).toHaveBeenCalledWith( + expect.stringContaining('/.local/share/kio/servicemenus/internxt-virtual-drive.desktop'), + 0o755, + ); + }); + }); + + describe('deleteExtensionFile', () => { + it('should remove installed assets for the detected file manager', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); + doesFileExistMock.mockResolvedValue(true); + + // When + await deleteExtensionFile(); + + // Then + expect(fsMock.rm).toHaveBeenCalled(); + }); + + it('should do nothing when no file manager is available', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce(null); + + // When + await deleteExtensionFile(); + + // Then + expect(fsMock.rm).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/backend/features/file-manager-extension/service.ts b/src/backend/features/file-manager-extension/service.ts index 9e3f4240bc..2cec6b0a2b 100644 --- a/src/backend/features/file-manager-extension/service.ts +++ b/src/backend/features/file-manager-extension/service.ts @@ -1,54 +1,110 @@ import { exec } from 'node:child_process'; import { logger } from '@internxt/drive-desktop-core/build/backend'; import fs from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import { doesFileExist } from '../../../apps/shared/fs/fileExists'; -import { detectAvailableFileManager, type FileManagerType } from './detect-available'; - -const extensionFileName = 'internxt-virtual-drive.py'; -const homedir = os.homedir(); +import { PATHS } from '../../../core/electron/paths'; +import { detectAvailableFileManager } from './detect-available'; +import { + type SupportedFileManager, + NAUTILUS_EXTENSION_FILENAME, + NEMO_EXTENSION_FILENAME, + DOLPHIN_MENU_FILENAME, + DOLPHIN_HELPER_FILENAME, +} from './constants'; + +type FileManagerAsset = { + source: string; + destination: string; + executable?: boolean; + template?: boolean; +}; type FileManagerConfig = { - type: FileManagerType; - destinationDir: string; + type: SupportedFileManager; reloadCommand: string; - extensionAssetDir: string; + assets: FileManagerAsset[]; }; +function isIgnorableReloadStderr({ + fileManagerType, + stderr, +}: { + fileManagerType: SupportedFileManager; + stderr: string; +}) { + if (fileManagerType !== 'dolphin') { + return false; + } + + return stderr.includes( + 'Application dolphin could not be found using service org.kde.dolphin and path /MainApplication.', + ); +} + async function getFileManagerConfig(): Promise { const fileManager = await detectAvailableFileManager(); + if (fileManager === 'dolphin') { + return { + type: 'dolphin', + reloadCommand: 'kquitapp6 dolphin || kquitapp5 dolphin || true', + assets: [ + { + source: 'dolphin/internxt-virtual-drive.desktop', + destination: path.join(PATHS.DOLPHIN_KIO_SERVICEMENUS_PATH, DOLPHIN_MENU_FILENAME), + template: true, + executable: true, + }, + { + source: 'dolphin/internxt-virtual-drive.desktop', + destination: path.join(PATHS.DOLPHIN_KSERVICES5_SERVICEMENUS_PATH, DOLPHIN_MENU_FILENAME), + template: true, + executable: true, + }, + { + source: `dolphin/${DOLPHIN_HELPER_FILENAME}`, + destination: path.join(PATHS.DOLPHIN_EXTENSION_PATH, DOLPHIN_HELPER_FILENAME), + executable: true, + }, + ], + }; + } + if (fileManager === 'nemo') { return { type: 'nemo', - destinationDir: `${homedir}/.local/share/nemo-python/extensions/`, reloadCommand: 'nemo -q', - extensionAssetDir: 'python-nemo', + assets: [ + { + source: `python-nemo/${NEMO_EXTENSION_FILENAME}`, + destination: path.join(PATHS.NEMO_EXTENSION_PATH, NEMO_EXTENSION_FILENAME), + }, + ], }; } if (fileManager === 'nautilus') { return { type: 'nautilus', - destinationDir: `${homedir}/.local/share/nautilus-python/extensions/`, reloadCommand: 'nautilus -q', - extensionAssetDir: 'python-nautilus', + assets: [ + { + source: `python-nautilus/${NAUTILUS_EXTENSION_FILENAME}`, + destination: path.join(PATHS.NAUTILUS_EXTENSION_PATH, NAUTILUS_EXTENSION_FILENAME), + }, + ], }; } return null; } -function getExtensionFile(assetDir: string): string { - if (process.env.NODE_ENV === 'development') { - return path.join(__dirname, `../../../../assets/${assetDir}`, extensionFileName); - } else { - return path.join(process.resourcesPath, 'assets', assetDir, extensionFileName); - } +function getExtensionFile(source: string): string { + return path.join(PATHS.RESOURCES_PATH, source); } -export async function getFileManagerType(): Promise { +export async function getFileManagerType(): Promise { return await detectAvailableFileManager(); } @@ -56,33 +112,52 @@ export async function isInstalled(): Promise { const config = await getFileManagerConfig(); if (!config) return false; - const destination = path.join(config.destinationDir, extensionFileName); - return await doesFileExist(destination); + const installedStates = await Promise.all(config.assets.map((asset) => doesFileExist(asset.destination))); + + return installedStates.every(Boolean); } export async function copyExtensionFile(): Promise { const config = await getFileManagerConfig(); if (!config) return; - const alreadyExists = await doesFileExist(path.join(config.destinationDir, extensionFileName)); - if (alreadyExists) return; + const alreadyInstalled = await isInstalled(); + if (alreadyInstalled) return; - const source = getExtensionFile(config.extensionAssetDir); - const destination = path.join(config.destinationDir, extensionFileName); + await Promise.all( + config.assets.map(async (asset) => { + const source = getExtensionFile(asset.source); + const destination = asset.destination; - await fs.mkdir(config.destinationDir, { - recursive: true, - }); + const destinationExists = await doesFileExist(destination); + if (destinationExists) { + if (asset.executable) { + await fs.chmod(destination, 0o755); + } + return; + } - if (process.env.NODE_ENV !== 'production') { - await fs.link(source, destination); - return; - } + await fs.mkdir(path.dirname(destination), { + recursive: true, + }); + + if (asset.template) { + const template = await fs.readFile(source, 'utf8'); + await fs.writeFile(destination, template.replaceAll('{{HOME}}', PATHS.HOME_FOLDER_PATH), 'utf8'); + } else if (process.env.NODE_ENV !== 'production') { + await fs.link(source, destination); + } else { + await fs.cp(source, destination); + } - await fs.cp(source, destination); + if (asset.executable) { + await fs.chmod(destination, 0o755); + } + }), + ); logger.debug({ - msg: `[FILE_MANAGER_EXTENSION] Added ${config.type} extension file to ${destination}`, + msg: `[FILE_MANAGER_EXTENSION] Added ${config.type} extension assets`, }); } @@ -90,14 +165,19 @@ export async function deleteExtensionFile(): Promise { const config = await getFileManagerConfig(); if (!config) return; - const destination = path.join(config.destinationDir, extensionFileName); - const isThere = await doesFileExist(destination); - if (!isThere) return; + await Promise.all( + config.assets.map(async (asset) => { + const isThere = await doesFileExist(asset.destination); + if (!isThere) { + return; + } - await fs.rm(destination); + await fs.rm(asset.destination); + }), + ); logger.debug({ - msg: `[FILE_MANAGER_EXTENSION] Deleted ${config.type} extension file from ${destination}`, + msg: `[FILE_MANAGER_EXTENSION] Deleted ${config.type} extension assets`, }); } @@ -125,7 +205,7 @@ export async function reloadFileManager(): Promise { return; } - if (stderr) { + if (stderr && !isIgnorableReloadStderr({ fileManagerType: config.type, stderr })) { reject(new Error(stderr)); return; } diff --git a/src/backend/features/file-manager-extension/version.ts b/src/backend/features/file-manager-extension/version.ts index 5464399629..aca7a910a9 100644 --- a/src/backend/features/file-manager-extension/version.ts +++ b/src/backend/features/file-manager-extension/version.ts @@ -1 +1,3 @@ +// This version must be updated whenever a major change is made to the extensions, +// since it forces logged-in users to reinstall the extension and apply the latest changes. export const LATEST_EXTENSION_VERSION = 3; diff --git a/src/core/electron/paths.ts b/src/core/electron/paths.ts index 704e6ea970..2b896fbe79 100644 --- a/src/core/electron/paths.ts +++ b/src/core/electron/paths.ts @@ -23,6 +23,17 @@ const FUSE_DAEMON_BINARY = app.isPackaged const RESOURCES_PATH = app.isPackaged ? path.join(process.resourcesPath, 'assets') : path.join(__dirname, '../../../assets'); +const NAUTILUS_EXTENSION_PATH = path.join(HOME_FOLDER_PATH, '.local', 'share', 'nautilus-python', 'extensions'); +const NEMO_EXTENSION_PATH = path.join(HOME_FOLDER_PATH, '.local', 'share', 'nemo-python', 'extensions'); +const DOLPHIN_KIO_SERVICEMENUS_PATH = path.join(HOME_FOLDER_PATH, '.local', 'share', 'kio', 'servicemenus'); +const DOLPHIN_KSERVICES5_SERVICEMENUS_PATH = path.join( + HOME_FOLDER_PATH, + '.local', + 'share', + 'kservices5', + 'ServiceMenus', +); +const DOLPHIN_EXTENSION_PATH = path.join(HOME_FOLDER_PATH, '.local', 'share', 'internxt-dolphin-extension'); export const PATHS = { VIRTUAL_DRIVE_FOLDER_NAME, @@ -40,4 +51,9 @@ export const PATHS = { FUSE_DAEMON_SOCKET, FUSE_DAEMON_BINARY, RESOURCES_PATH, + NAUTILUS_EXTENSION_PATH, + NEMO_EXTENSION_PATH, + DOLPHIN_KIO_SERVICEMENUS_PATH, + DOLPHIN_KSERVICES5_SERVICEMENUS_PATH, + DOLPHIN_EXTENSION_PATH, }; diff --git a/tests/smoke/file-manager-extension/smoke-electron-mock.cjs b/tests/smoke/file-manager-extension/smoke-electron-mock.cjs new file mode 100644 index 0000000000..a73fb6ab17 --- /dev/null +++ b/tests/smoke/file-manager-extension/smoke-electron-mock.cjs @@ -0,0 +1,40 @@ +const Module = require('node:module'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const originalLoad = Module._load; +const workspaceTempDir = path.join(process.cwd(), '.tmp', 'file-manager-smoke-temp'); + +if (!fs.existsSync(workspaceTempDir)) { + fs.mkdirSync(workspaceTempDir, { recursive: true, mode: 0o700 }); +} + +function getPath(name) { + if (name === 'home') { + return os.homedir(); + } + + if (name === 'appData') { + return path.join(os.homedir(), '.config'); + } + + if (name === 'temp') { + return process.env.TMPDIR || workspaceTempDir; + } + + return process.cwd(); +} + +Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'electron') { + return { + app: { + getPath, + isPackaged: false, + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); +};