From 6d2babda62183b78a4c9f84e596fc98ecc1d7947 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Tue, 21 Jul 2026 12:27:14 -0500 Subject: [PATCH 01/10] feat: Add Dolphin file manager support and extension assets --- assets/dolphin/internxt-dolphin-actions.sh | 94 +++++++++++++ assets/dolphin/internxt-virtual-drive.desktop | 14 ++ package.json | 4 +- .../detect-available.test.ts | 54 +++++++- .../detect-available.ts | 26 +++- .../file-manager-extension/install.test.ts | 16 +++ .../file-manager-extension/install.ts | 2 +- .../file-manager-extension/service.test.ts | 71 ++++++++++ .../file-manager-extension/service.ts | 130 +++++++++++++----- 9 files changed, 374 insertions(+), 37 deletions(-) create mode 100644 assets/dolphin/internxt-dolphin-actions.sh create mode 100644 assets/dolphin/internxt-virtual-drive.desktop diff --git a/assets/dolphin/internxt-dolphin-actions.sh b/assets/dolphin/internxt-dolphin-actions.sh new file mode 100644 index 000000000..71c8decdd --- /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 000000000..135727031 --- /dev/null +++ b/assets/dolphin/internxt-virtual-drive.desktop @@ -0,0 +1,14 @@ +[Desktop Entry] +Type=Service +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 -lc '"$HOME/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh" copy-link "$@"' _ %F diff --git a/package.json b/package.json index cf3ea00d7..66be725f9 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "category": "Development" }, "deb": { - "depends": [ + "recommends": [ "python3-nautilus" ] }, @@ -253,4 +253,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/detect-available.test.ts b/src/backend/features/file-manager-extension/detect-available.test.ts index 983f7e495..c06aa982d 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, isDolphinAvailable, isNautilusAvailable, isNemoAvailable } 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({}); @@ -139,4 +170,23 @@ describe('detect-available', () => { expect(result).toBe(false); }); }); + + describe('isDolphinAvailable', () => { + it('should return true when dolphin is available', async () => { + mockExecWith({ + desktopEntry: 'org.kde.dolphin.desktop', + hasDolphin: true, + }); + + const result = await isDolphinAvailable(); + expect(result).toBe(true); + }); + + it('should return false when dolphin is not available', async () => { + mockExecWith({}); + + const result = await isDolphinAvailable(); + 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 4d5cbf7f3..9d8c12df4 100644 --- a/src/backend/features/file-manager-extension/detect-available.ts +++ b/src/backend/features/file-manager-extension/detect-available.ts @@ -3,12 +3,18 @@ import { promisify } from 'node:util'; const execAsync = promisify(exec); -export type FileManagerType = 'nautilus' | 'nemo' | null; +export type FileManagerType = 'nautilus' | 'nemo' | 'dolphin' | null; export async function detectAvailableFileManager(): Promise { const desktopEntry = await getDefaultDirectoryDesktopEntry(); if (desktopEntry) { + if (desktopEntry.includes('dolphin.desktop')) { + if (await hasDolphinBinary()) { + return 'dolphin'; + } + } + if (desktopEntry.includes('nemo.desktop')) { if (await hasNemoBinary()) { return 'nemo'; @@ -23,6 +29,11 @@ export async function detectAvailableFileManager(): Promise { } // Fallback: check for available binaries + const hasDolphin = await hasDolphinBinary(); + if (hasDolphin) { + return 'dolphin'; + } + const hasNemo = await hasNemoBinary(); if (hasNemo) { return 'nemo'; @@ -44,6 +55,10 @@ export async function isNemoAvailable(): Promise { return (await detectAvailableFileManager()) === 'nemo'; } +export async function isDolphinAvailable(): Promise { + return (await detectAvailableFileManager()) === 'dolphin'; +} + async function getDefaultDirectoryDesktopEntry(): Promise { try { const { stdout } = await execAsync('xdg-mime query default inode/directory'); @@ -70,3 +85,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/install.test.ts b/src/backend/features/file-manager-extension/install.test.ts index f3c3470a5..8adacd80b 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 f212a7119..1aa5de644 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 e17de12e2..50de0ee91 100644 --- a/src/backend/features/file-manager-extension/service.test.ts +++ b/src/backend/features/file-manager-extension/service.test.ts @@ -53,6 +53,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 +123,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', () => { @@ -156,6 +181,52 @@ describe('service', () => { expect(execMock).toHaveBeenCalled(); }); + it('should execute dolphin reload command when dolphin is available', async () => { + // Given + detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); + execMock.mockImplementation((cmd, callback) => { + expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); + callback(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, callback) => { + expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); + callback( + 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, callback) => { + expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); + callback(null, '', 'unexpected dolphin stderr'); + }); + + // When / Then + await expect(reloadFileManager()).rejects.toThrow('unexpected dolphin stderr'); + }); + it('should handle exit code 255 gracefully', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); diff --git a/src/backend/features/file-manager-extension/service.ts b/src/backend/features/file-manager-extension/service.ts index 32238193d..04c9a6917 100644 --- a/src/backend/features/file-manager-extension/service.ts +++ b/src/backend/features/file-manager-extension/service.ts @@ -6,45 +6,92 @@ 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(); +const nautilusExtensionFileName = 'internxt-virtual-drive.py'; +const nemoExtensionFileName = 'internxt-virtual-drive.py'; +const dolphinMenuFileName = 'internxt-virtual-drive.desktop'; +const dolphinHelperFileName = 'internxt-dolphin-actions.sh'; + +type FileManagerAsset = { + source: string; + destination: string; + executable?: boolean; +}; + type FileManagerConfig = { type: FileManagerType; - destinationDir: string; reloadCommand: string; - extensionAssetDir: string; + assets: FileManagerAsset[]; }; +function isIgnorableReloadStderr({ fileManagerType, stderr }: { fileManagerType: FileManagerType; 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: `${homedir}/.local/share/kio/servicemenus/${dolphinMenuFileName}`, + }, + { + source: 'dolphin/internxt-virtual-drive.desktop', + destination: `${homedir}/.local/share/kservices5/ServiceMenus/${dolphinMenuFileName}`, + }, + { + source: `dolphin/${dolphinHelperFileName}`, + destination: `${homedir}/.local/share/internxt-dolphin-extension/${dolphinHelperFileName}`, + 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/${nemoExtensionFileName}`, + destination: `${homedir}/.local/share/nemo-python/extensions/${nemoExtensionFileName}`, + }, + ], }; } if (fileManager === 'nautilus') { return { type: 'nautilus', - destinationDir: `${homedir}/.local/share/nautilus-python/extensions/`, reloadCommand: 'nautilus -q', - extensionAssetDir: 'python-nautilus', + assets: [ + { + source: `python-nautilus/${nautilusExtensionFileName}`, + destination: `${homedir}/.local/share/nautilus-python/extensions/${nautilusExtensionFileName}`, + }, + ], }; } return null; } -function getExtensionFile(assetDir: string): string { +function getExtensionFile(source: string): string { if (process.env.NODE_ENV === 'development') { - return path.join(__dirname, `../../../../assets/${assetDir}`, extensionFileName); + return path.join(__dirname, `../../../../assets/${source}`); } else { - return path.join(process.resourcesPath, 'assets', assetDir, extensionFileName); + return path.join(process.resourcesPath, 'assets', source); } } @@ -56,33 +103,49 @@ 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 (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 +153,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`, }); } @@ -119,7 +187,7 @@ export async function reloadFileManager(): Promise { return; } - if (stderr) { + if (stderr && !isIgnorableReloadStderr({ fileManagerType: config.type, stderr })) { reject(new Error(stderr)); return; } From 30f07312a632bcea341965376fc56a0ac64a0c5b Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Tue, 21 Jul 2026 20:03:58 -0500 Subject: [PATCH 02/10] feat: Update Dolphin extension and tests for improved functionality --- assets/dolphin/internxt-virtual-drive.desktop | 3 +- .../detect-available.test.ts | 7 ++- .../file-manager-extension/service.test.ts | 34 +++++++++++- .../file-manager-extension/service.ts | 52 +++++++++++-------- .../file-manager-extension/version.ts | 2 +- 5 files changed, 73 insertions(+), 25 deletions(-) diff --git a/assets/dolphin/internxt-virtual-drive.desktop b/assets/dolphin/internxt-virtual-drive.desktop index 135727031..e17b792ed 100644 --- a/assets/dolphin/internxt-virtual-drive.desktop +++ b/assets/dolphin/internxt-virtual-drive.desktop @@ -1,5 +1,6 @@ [Desktop Entry] Type=Service +Name=Internxt Drive Actions MimeType=all/allfiles;inode/directory; ServiceTypes=KonqPopupMenu/Plugin X-KDE-ServiceTypes=KonqPopupMenu/Plugin @@ -11,4 +12,4 @@ X-KDE-StartupNotify=false [Desktop Action InternxtCopyLink] Name=Copy Internxt Link Icon=insert-link -Exec=/usr/bin/env bash -lc '"$HOME/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh" copy-link "$@"' _ %F +Exec=/usr/bin/env bash {{HOME}}/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh copy-link %f 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 c06aa982d..f008baaca 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,9 @@ -import { detectAvailableFileManager, isDolphinAvailable, isNautilusAvailable, isNemoAvailable } from './detect-available'; +import { + detectAvailableFileManager, + isDolphinAvailable, + isNautilusAvailable, + isNemoAvailable, +} from './detect-available'; const { execAsyncMock } = vi.hoisted(() => ({ execAsyncMock: vi.fn(), diff --git a/src/backend/features/file-manager-extension/service.test.ts b/src/backend/features/file-manager-extension/service.test.ts index 50de0ee91..b70bd6432 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, getFileManagerType, isInstalled, reloadFileManager } from './service'; import { partialSpyOn } from 'tests/vitest/utils.helper'; +import { homedir } from 'node:os'; 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); }); @@ -242,4 +249,29 @@ describe('service', () => { expect(true).toBe(true); }); }); + + describe('copyExtensionFile', () => { + it('should template dolphin service menu with the current 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(`${homedir}/.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, + ); + }); + }); }); diff --git a/src/backend/features/file-manager-extension/service.ts b/src/backend/features/file-manager-extension/service.ts index 04c9a6917..1a6179261 100644 --- a/src/backend/features/file-manager-extension/service.ts +++ b/src/backend/features/file-manager-extension/service.ts @@ -17,6 +17,7 @@ type FileManagerAsset = { source: string; destination: string; executable?: boolean; + template?: boolean; }; type FileManagerConfig = { @@ -30,7 +31,9 @@ function isIgnorableReloadStderr({ fileManagerType, stderr }: { fileManagerType: return false; } - return stderr.includes('Application dolphin could not be found using service org.kde.dolphin and path /MainApplication.'); + return stderr.includes( + 'Application dolphin could not be found using service org.kde.dolphin and path /MainApplication.', + ); } async function getFileManagerConfig(): Promise { @@ -44,10 +47,14 @@ async function getFileManagerConfig(): Promise { { source: 'dolphin/internxt-virtual-drive.desktop', destination: `${homedir}/.local/share/kio/servicemenus/${dolphinMenuFileName}`, + template: true, + executable: true, }, { source: 'dolphin/internxt-virtual-drive.desktop', destination: `${homedir}/.local/share/kservices5/ServiceMenus/${dolphinMenuFileName}`, + template: true, + executable: true, }, { source: `dolphin/${dolphinHelperFileName}`, @@ -117,30 +124,33 @@ export async function copyExtensionFile(): Promise { await Promise.all( config.assets.map(async (asset) => { - const source = getExtensionFile(asset.source); - const destination = asset.destination; + const source = getExtensionFile(asset.source); + const destination = asset.destination; - const destinationExists = await doesFileExist(destination); - if (destinationExists) { - if (asset.executable) { - await fs.chmod(destination, 0o755); + const destinationExists = await doesFileExist(destination); + if (destinationExists) { + if (asset.executable) { + await fs.chmod(destination, 0o755); + } + return; } - return; - } - await fs.mkdir(path.dirname(destination), { - recursive: true, - }); - - if (process.env.NODE_ENV !== 'production') { - await fs.link(source, destination); - } else { - await fs.cp(source, destination); - } + 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}}', homedir), 'utf8'); + } else if (process.env.NODE_ENV !== 'production') { + await fs.link(source, destination); + } else { + await fs.cp(source, destination); + } - if (asset.executable) { - await fs.chmod(destination, 0o755); - } + if (asset.executable) { + await fs.chmod(destination, 0o755); + } }), ); diff --git a/src/backend/features/file-manager-extension/version.ts b/src/backend/features/file-manager-extension/version.ts index 546439962..3d75b5d59 100644 --- a/src/backend/features/file-manager-extension/version.ts +++ b/src/backend/features/file-manager-extension/version.ts @@ -1 +1 @@ -export const LATEST_EXTENSION_VERSION = 3; +export const LATEST_EXTENSION_VERSION = 4; From b3574772476749264598cb3c2229c17e403f1674 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Wed, 22 Jul 2026 08:32:33 -0500 Subject: [PATCH 03/10] feat: Refactor file manager detection logic for improved maintainability --- .eslintrc.js | 1 - .../detect-available.ts | 47 ++++++++----------- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 92f3a8cbe..4dbbfddd4 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/src/backend/features/file-manager-extension/detect-available.ts b/src/backend/features/file-manager-extension/detect-available.ts index 9d8c12df4..52e72c082 100644 --- a/src/backend/features/file-manager-extension/detect-available.ts +++ b/src/backend/features/file-manager-extension/detect-available.ts @@ -5,43 +5,34 @@ const execAsync = promisify(exec); export type FileManagerType = 'nautilus' | 'nemo' | 'dolphin' | null; +type FileManagerCandidate = { + type: Exclude; + desktopEntry: string; + hasBinary: () => 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('dolphin.desktop')) { - if (await hasDolphinBinary()) { - return 'dolphin'; - } - } - - 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 hasDolphin = await hasDolphinBinary(); - if (hasDolphin) { - return 'dolphin'; - } - - 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; From 2aea7889b9621d26774fe8b371c2430fc13264c4 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Thu, 30 Jul 2026 11:11:03 -0500 Subject: [PATCH 04/10] feat: Add documentation for prerequisites and implement smoke tests for file system extension (#421) --- .../file-manager-extension-smoke.yml | 64 ++++++++ README.md | 66 +++++++- package.json | 1 + .../file-manager-extension/constants.ts | 6 + .../file-manager-extension-smoke.ts | 152 ++++++++++++++++++ 5 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/file-manager-extension-smoke.yml create mode 100644 src/backend/features/file-manager-extension/constants.ts create mode 100644 src/backend/features/file-manager-extension/file-manager-extension-smoke.ts diff --git a/.github/workflows/file-manager-extension-smoke.yml b/.github/workflows/file-manager-extension-smoke.yml new file mode 100644 index 000000000..d1df238cc --- /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:24.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 f4d8d8aec..3d55ff2c7 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/package.json b/package.json index 66be725f9..731f214c3 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 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", 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 000000000..bb0e1da5f --- /dev/null +++ b/src/backend/features/file-manager-extension/constants.ts @@ -0,0 +1,6 @@ +const supportedFileManagers = ['nautilus', 'nemo', 'dolphin'] as const; +type SupportedFileManager = (typeof supportedFileManagers)[number]; + +export function isSupportedFileManager(value: string): value is SupportedFileManager { + return supportedFileManagers.some((sfm) => sfm === value); +} 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 000000000..f21f63f1e --- /dev/null +++ b/src/backend/features/file-manager-extension/file-manager-extension-smoke.ts @@ -0,0 +1,152 @@ +/* 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, type FileManagerType } from './detect-available'; +import { isSupportedFileManager } from './constants'; + +type SupportedFileManager = Exclude; + +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(); + + 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}`, + }); + } + + const manager = detected as SupportedFileManager; + 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); +}); From 63326dced946291856681fd2270023967512b8fb Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Fri, 31 Jul 2026 08:38:41 -0500 Subject: [PATCH 05/10] fix: Remove unnecessary blank line in service tests for file manager extension --- .../file-manager-extension/service.test.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/backend/features/file-manager-extension/service.test.ts b/src/backend/features/file-manager-extension/service.test.ts index 021c90b71..554d99354 100644 --- a/src/backend/features/file-manager-extension/service.test.ts +++ b/src/backend/features/file-manager-extension/service.test.ts @@ -197,9 +197,11 @@ describe('service', () => { it('should execute dolphin reload command when dolphin is available', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); - execMock.mockImplementation((cmd, callback) => { + execMock.mockImplementation((cmd, optionsOrCallback, callback) => { expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); - callback(null, '', ''); + + const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + cb?.(null, '', ''); }); // When @@ -212,9 +214,11 @@ describe('service', () => { it('should ignore dolphin stderr when dolphin is not running', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); - execMock.mockImplementation((cmd, callback) => { + execMock.mockImplementation((cmd, optionsOrCallback, callback) => { expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); - callback( + + const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + cb?.( null, '', 'Application dolphin could not be found using service org.kde.dolphin and path /MainApplication.', @@ -231,15 +235,17 @@ describe('service', () => { it('should reject unexpected dolphin stderr', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); - execMock.mockImplementation((cmd, callback) => { + execMock.mockImplementation((cmd, optionsOrCallback, callback) => { expect(cmd).toBe('kquitapp6 dolphin || kquitapp5 dolphin || true'); - callback(null, '', 'unexpected dolphin stderr'); + + 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'); From 8905df49180ec95cf22bd80978e5207da79fa230 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Fri, 31 Jul 2026 16:43:06 -0500 Subject: [PATCH 06/10] feat: Update file manager extension to support null values and improve file handling --- .../file-manager-extension-smoke.yml | 2 +- .../file-manager-extension/constants.ts | 14 +++- .../detect-available.test.ts | 40 --------- .../detect-available.ts | 15 +--- .../file-manager-extension-smoke.ts | 14 ++-- .../file-manager-extension/service.test.ts | 81 ++++++++++++++++++- .../file-manager-extension/service.ts | 36 +++++---- .../file-manager-extension/version.ts | 4 +- 8 files changed, 124 insertions(+), 82 deletions(-) diff --git a/.github/workflows/file-manager-extension-smoke.yml b/.github/workflows/file-manager-extension-smoke.yml index d1df238cc..8bfacf12d 100644 --- a/.github/workflows/file-manager-extension-smoke.yml +++ b/.github/workflows/file-manager-extension-smoke.yml @@ -14,7 +14,7 @@ jobs: fileManager: [nautilus, nemo, dolphin] container: - image: ubuntu:24.04 + image: ubuntu:22.04 steps: - name: Install base dependencies diff --git a/src/backend/features/file-manager-extension/constants.ts b/src/backend/features/file-manager-extension/constants.ts index bb0e1da5f..625c2d45b 100644 --- a/src/backend/features/file-manager-extension/constants.ts +++ b/src/backend/features/file-manager-extension/constants.ts @@ -1,6 +1,12 @@ -const supportedFileManagers = ['nautilus', 'nemo', 'dolphin'] as const; -type SupportedFileManager = (typeof supportedFileManagers)[number]; +const supportedFileManagers = ['nautilus', 'nemo', 'dolphin', null] as const; +export type SupportedFileManager = (typeof supportedFileManagers)[number]; -export function isSupportedFileManager(value: string): value is SupportedFileManager { - return supportedFileManagers.some((sfm) => sfm === value); +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 f008baaca..c5dfa6538 100644 --- a/src/backend/features/file-manager-extension/detect-available.test.ts +++ b/src/backend/features/file-manager-extension/detect-available.test.ts @@ -1,8 +1,6 @@ import { detectAvailableFileManager, - isDolphinAvailable, isNautilusAvailable, - isNemoAvailable, } from './detect-available'; const { execAsyncMock } = vi.hoisted(() => ({ @@ -156,42 +154,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); - }); - }); - - describe('isDolphinAvailable', () => { - it('should return true when dolphin is available', async () => { - mockExecWith({ - desktopEntry: 'org.kde.dolphin.desktop', - hasDolphin: true, - }); - - const result = await isDolphinAvailable(); - expect(result).toBe(true); - }); - - it('should return false when dolphin is not available', async () => { - mockExecWith({}); - - const result = await isDolphinAvailable(); - 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 52e72c082..9d1f9ab0c 100644 --- a/src/backend/features/file-manager-extension/detect-available.ts +++ b/src/backend/features/file-manager-extension/detect-available.ts @@ -1,12 +1,11 @@ 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' | 'dolphin' | null; - type FileManagerCandidate = { - type: Exclude; + type: SupportedFileManager; desktopEntry: string; hasBinary: () => Promise; }; @@ -17,7 +16,7 @@ const FILE_MANAGER_CANDIDATES: FileManagerCandidate[] = [ { type: 'nautilus', desktopEntry: 'nautilus.desktop', hasBinary: hasNautilusBinary }, ]; -export async function detectAvailableFileManager(): Promise { +export async function detectAvailableFileManager(): Promise { const desktopEntry = await getDefaultDirectoryDesktopEntry(); if (desktopEntry) { @@ -42,14 +41,6 @@ export async function isNautilusAvailable(): Promise { return (await detectAvailableFileManager()) === 'nautilus'; } -export async function isNemoAvailable(): Promise { - return (await detectAvailableFileManager()) === 'nemo'; -} - -export async function isDolphinAvailable(): Promise { - return (await detectAvailableFileManager()) === 'dolphin'; -} - async function getDefaultDirectoryDesktopEntry(): Promise { try { const { stdout } = await execAsync('xdg-mime query default inode/directory'); 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 index f21f63f1e..a11b0a178 100644 --- a/src/backend/features/file-manager-extension/file-manager-extension-smoke.ts +++ b/src/backend/features/file-manager-extension/file-manager-extension-smoke.ts @@ -4,10 +4,8 @@ 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, type FileManagerType } from './detect-available'; -import { isSupportedFileManager } from './constants'; - -type SupportedFileManager = Exclude; +import { detectAvailableFileManager } from './detect-available'; +import { isSupportedFileManager, type SupportedFileManager } from './constants'; type AssertProps = { condition: boolean; @@ -21,7 +19,7 @@ function assertOrThrow({ condition, message }: AssertProps) { } function expectedManagerFromEnv() { - const raw = process.env.EXPECTED_FILE_MANAGER?.trim().toLowerCase(); + const raw = process.env.EXPECTED_FILE_MANAGER?.trim().toLowerCase() as SupportedFileManager; if (!raw) return null; @@ -98,7 +96,11 @@ async function run() { }); } - const manager = detected as SupportedFileManager; + if (detected === null) { + throw new Error('No supported file manager detected in smoke environment'); + } + + const manager = detected; const expectedPaths = getExpectedPaths({ manager }); await deleteExtensionFile(); diff --git a/src/backend/features/file-manager-extension/service.test.ts b/src/backend/features/file-manager-extension/service.test.ts index 554d99354..8dd7a6ec2 100644 --- a/src/backend/features/file-manager-extension/service.test.ts +++ b/src/backend/features/file-manager-extension/service.test.ts @@ -1,7 +1,7 @@ import * as detectModule from './detect-available'; import * as fileExistsModule from '../../../apps/shared/fs/fileExists'; import fs from 'node:fs/promises'; -import { copyExtensionFile, getFileManagerType, isInstalled, reloadFileManager } from './service'; +import { copyExtensionFile, deleteExtensionFile, getFileManagerType, isInstalled, reloadFileManager } from './service'; import { partialSpyOn } from 'tests/vitest/utils.helper'; import { homedir } from 'node:os'; @@ -296,6 +296,60 @@ describe('service', () => { }); 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 current home path', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); @@ -319,4 +373,29 @@ describe('service', () => { ); }); }); + + 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 cc0e7b73d..a5310d35e 100644 --- a/src/backend/features/file-manager-extension/service.ts +++ b/src/backend/features/file-manager-extension/service.ts @@ -4,15 +4,17 @@ 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'; +import { detectAvailableFileManager } from './detect-available'; +import { + type SupportedFileManager, + NAUTILUS_EXTENSION_FILENAME, + NEMO_EXTENSION_FILENAME, + DOLPHIN_MENU_FILENAME, + DOLPHIN_HELPER_FILENAME, +} from './constants'; const homedir = os.homedir(); -const nautilusExtensionFileName = 'internxt-virtual-drive.py'; -const nemoExtensionFileName = 'internxt-virtual-drive.py'; -const dolphinMenuFileName = 'internxt-virtual-drive.desktop'; -const dolphinHelperFileName = 'internxt-dolphin-actions.sh'; - type FileManagerAsset = { source: string; destination: string; @@ -21,12 +23,12 @@ type FileManagerAsset = { }; type FileManagerConfig = { - type: FileManagerType; + type: SupportedFileManager; reloadCommand: string; assets: FileManagerAsset[]; }; -function isIgnorableReloadStderr({ fileManagerType, stderr }: { fileManagerType: FileManagerType; stderr: string }) { +function isIgnorableReloadStderr({ fileManagerType, stderr }: { fileManagerType: SupportedFileManager; stderr: string }) { if (fileManagerType !== 'dolphin') { return false; } @@ -46,19 +48,19 @@ async function getFileManagerConfig(): Promise { assets: [ { source: 'dolphin/internxt-virtual-drive.desktop', - destination: `${homedir}/.local/share/kio/servicemenus/${dolphinMenuFileName}`, + destination: `${homedir}/.local/share/kio/servicemenus/${DOLPHIN_MENU_FILENAME}`, template: true, executable: true, }, { source: 'dolphin/internxt-virtual-drive.desktop', - destination: `${homedir}/.local/share/kservices5/ServiceMenus/${dolphinMenuFileName}`, + destination: `${homedir}/.local/share/kservices5/ServiceMenus/${DOLPHIN_MENU_FILENAME}`, template: true, executable: true, }, { - source: `dolphin/${dolphinHelperFileName}`, - destination: `${homedir}/.local/share/internxt-dolphin-extension/${dolphinHelperFileName}`, + source: `dolphin/${DOLPHIN_HELPER_FILENAME}`, + destination: `${homedir}/.local/share/internxt-dolphin-extension/${DOLPHIN_HELPER_FILENAME}`, executable: true, }, ], @@ -71,8 +73,8 @@ async function getFileManagerConfig(): Promise { reloadCommand: 'nemo -q', assets: [ { - source: `python-nemo/${nemoExtensionFileName}`, - destination: `${homedir}/.local/share/nemo-python/extensions/${nemoExtensionFileName}`, + source: `python-nemo/${NEMO_EXTENSION_FILENAME}`, + destination: `${homedir}/.local/share/nemo-python/extensions/${NEMO_EXTENSION_FILENAME}`, }, ], }; @@ -84,8 +86,8 @@ async function getFileManagerConfig(): Promise { reloadCommand: 'nautilus -q', assets: [ { - source: `python-nautilus/${nautilusExtensionFileName}`, - destination: `${homedir}/.local/share/nautilus-python/extensions/${nautilusExtensionFileName}`, + source: `python-nautilus/${NAUTILUS_EXTENSION_FILENAME}`, + destination: `${homedir}/.local/share/nautilus-python/extensions/${NAUTILUS_EXTENSION_FILENAME}`, }, ], }; @@ -102,7 +104,7 @@ function getExtensionFile(source: string): string { } } -export async function getFileManagerType(): Promise { +export async function getFileManagerType(): Promise { return await detectAvailableFileManager(); } diff --git a/src/backend/features/file-manager-extension/version.ts b/src/backend/features/file-manager-extension/version.ts index 3d75b5d59..aca7a910a 100644 --- a/src/backend/features/file-manager-extension/version.ts +++ b/src/backend/features/file-manager-extension/version.ts @@ -1 +1,3 @@ -export const LATEST_EXTENSION_VERSION = 4; +// 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; From 84a2cdd4eccebd32fa23dd50a18b43f20bde404a Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Fri, 31 Jul 2026 16:50:28 -0500 Subject: [PATCH 07/10] feat: Refactor file manager extension constants and tests for improved clarity and functionality --- src/backend/features/file-manager-extension/constants.ts | 1 - .../file-manager-extension/detect-available.test.ts | 5 +---- .../features/file-manager-extension/service.test.ts | 1 + src/backend/features/file-manager-extension/service.ts | 8 +++++++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/backend/features/file-manager-extension/constants.ts b/src/backend/features/file-manager-extension/constants.ts index 625c2d45b..ac45d4ee2 100644 --- a/src/backend/features/file-manager-extension/constants.ts +++ b/src/backend/features/file-manager-extension/constants.ts @@ -6,7 +6,6 @@ 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 c5dfa6538..894936aa5 100644 --- a/src/backend/features/file-manager-extension/detect-available.test.ts +++ b/src/backend/features/file-manager-extension/detect-available.test.ts @@ -1,7 +1,4 @@ -import { - detectAvailableFileManager, - isNautilusAvailable, -} from './detect-available'; +import { detectAvailableFileManager, isNautilusAvailable } from './detect-available'; const { execAsyncMock } = vi.hoisted(() => ({ execAsyncMock: vi.fn(), diff --git a/src/backend/features/file-manager-extension/service.test.ts b/src/backend/features/file-manager-extension/service.test.ts index 8dd7a6ec2..1fc0e2a5a 100644 --- a/src/backend/features/file-manager-extension/service.test.ts +++ b/src/backend/features/file-manager-extension/service.test.ts @@ -339,6 +339,7 @@ describe('service', () => { it('should use fs.cp for non-template assets in production mode', async () => { // Given process.env.NODE_ENV = 'production'; + process.resourcesPath = '/tmp/resources'; detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); doesFileExistMock.mockResolvedValue(false); diff --git a/src/backend/features/file-manager-extension/service.ts b/src/backend/features/file-manager-extension/service.ts index a5310d35e..88daf41ce 100644 --- a/src/backend/features/file-manager-extension/service.ts +++ b/src/backend/features/file-manager-extension/service.ts @@ -28,7 +28,13 @@ type FileManagerConfig = { assets: FileManagerAsset[]; }; -function isIgnorableReloadStderr({ fileManagerType, stderr }: { fileManagerType: SupportedFileManager; stderr: string }) { +function isIgnorableReloadStderr({ + fileManagerType, + stderr, +}: { + fileManagerType: SupportedFileManager; + stderr: string; +}) { if (fileManagerType !== 'dolphin') { return false; } From 1b17de5b4d8566114910b092c692913b3b76ff50 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 3 Aug 2026 08:08:20 -0500 Subject: [PATCH 08/10] feat: Integrate PATHS constants for Dolphin file system extension and update service logic --- .../file-manager-extension/service.test.ts | 8 +++---- .../file-manager-extension/service.ts | 22 +++++++------------ src/core/electron/paths.ts | 10 +++++++++ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/backend/features/file-manager-extension/service.test.ts b/src/backend/features/file-manager-extension/service.test.ts index 1fc0e2a5a..f66a8a197 100644 --- a/src/backend/features/file-manager-extension/service.test.ts +++ b/src/backend/features/file-manager-extension/service.test.ts @@ -3,7 +3,7 @@ import * as fileExistsModule from '../../../apps/shared/fs/fileExists'; import fs from 'node:fs/promises'; import { copyExtensionFile, deleteExtensionFile, getFileManagerType, isInstalled, reloadFileManager } from './service'; import { partialSpyOn } from 'tests/vitest/utils.helper'; -import { homedir } from 'node:os'; +import { PATHS } from '../../../core/electron/paths'; const { execMock } = vi.hoisted(() => ({ execMock: vi.fn(), @@ -339,7 +339,7 @@ describe('service', () => { it('should use fs.cp for non-template assets in production mode', async () => { // Given process.env.NODE_ENV = 'production'; - process.resourcesPath = '/tmp/resources'; + PATHS.RESOURCES_PATH = '/tmp/resources'; detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); doesFileExistMock.mockResolvedValue(false); @@ -351,7 +351,7 @@ describe('service', () => { expect(fsMock.link).not.toHaveBeenCalled(); }); - it('should template dolphin service menu with the current home path', async () => { + it('should template dolphin service menu with the shared home path', async () => { // Given detectAvailableFileManagerMock.mockResolvedValueOnce('dolphin'); doesFileExistMock.mockResolvedValue(false); @@ -365,7 +365,7 @@ describe('service', () => { // Then expect(fsMock.writeFile).toHaveBeenCalledWith( expect.stringContaining('/.local/share/kio/servicemenus/internxt-virtual-drive.desktop'), - expect.stringContaining(`${homedir}/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh`), + expect.stringContaining(`${PATHS.HOME_FOLDER_PATH}/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh`), 'utf8', ); expect(fsMock.chmod).toHaveBeenCalledWith( diff --git a/src/backend/features/file-manager-extension/service.ts b/src/backend/features/file-manager-extension/service.ts index 88daf41ce..2cec6b0a2 100644 --- a/src/backend/features/file-manager-extension/service.ts +++ b/src/backend/features/file-manager-extension/service.ts @@ -1,9 +1,9 @@ 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 { PATHS } from '../../../core/electron/paths'; import { detectAvailableFileManager } from './detect-available'; import { type SupportedFileManager, @@ -13,8 +13,6 @@ import { DOLPHIN_HELPER_FILENAME, } from './constants'; -const homedir = os.homedir(); - type FileManagerAsset = { source: string; destination: string; @@ -54,19 +52,19 @@ async function getFileManagerConfig(): Promise { assets: [ { source: 'dolphin/internxt-virtual-drive.desktop', - destination: `${homedir}/.local/share/kio/servicemenus/${DOLPHIN_MENU_FILENAME}`, + destination: path.join(PATHS.DOLPHIN_KIO_SERVICEMENUS_PATH, DOLPHIN_MENU_FILENAME), template: true, executable: true, }, { source: 'dolphin/internxt-virtual-drive.desktop', - destination: `${homedir}/.local/share/kservices5/ServiceMenus/${DOLPHIN_MENU_FILENAME}`, + destination: path.join(PATHS.DOLPHIN_KSERVICES5_SERVICEMENUS_PATH, DOLPHIN_MENU_FILENAME), template: true, executable: true, }, { source: `dolphin/${DOLPHIN_HELPER_FILENAME}`, - destination: `${homedir}/.local/share/internxt-dolphin-extension/${DOLPHIN_HELPER_FILENAME}`, + destination: path.join(PATHS.DOLPHIN_EXTENSION_PATH, DOLPHIN_HELPER_FILENAME), executable: true, }, ], @@ -80,7 +78,7 @@ async function getFileManagerConfig(): Promise { assets: [ { source: `python-nemo/${NEMO_EXTENSION_FILENAME}`, - destination: `${homedir}/.local/share/nemo-python/extensions/${NEMO_EXTENSION_FILENAME}`, + destination: path.join(PATHS.NEMO_EXTENSION_PATH, NEMO_EXTENSION_FILENAME), }, ], }; @@ -93,7 +91,7 @@ async function getFileManagerConfig(): Promise { assets: [ { source: `python-nautilus/${NAUTILUS_EXTENSION_FILENAME}`, - destination: `${homedir}/.local/share/nautilus-python/extensions/${NAUTILUS_EXTENSION_FILENAME}`, + destination: path.join(PATHS.NAUTILUS_EXTENSION_PATH, NAUTILUS_EXTENSION_FILENAME), }, ], }; @@ -103,11 +101,7 @@ async function getFileManagerConfig(): Promise { } function getExtensionFile(source: string): string { - if (process.env.NODE_ENV === 'development') { - return path.join(__dirname, `../../../../assets/${source}`); - } else { - return path.join(process.resourcesPath, 'assets', source); - } + return path.join(PATHS.RESOURCES_PATH, source); } export async function getFileManagerType(): Promise { @@ -149,7 +143,7 @@ export async function copyExtensionFile(): Promise { if (asset.template) { const template = await fs.readFile(source, 'utf8'); - await fs.writeFile(destination, template.replaceAll('{{HOME}}', homedir), '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 { diff --git a/src/core/electron/paths.ts b/src/core/electron/paths.ts index 704e6ea97..89dc8e7f1 100644 --- a/src/core/electron/paths.ts +++ b/src/core/electron/paths.ts @@ -23,6 +23,11 @@ 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 +45,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, }; From 836ee7b41d3ced289e8fbb74ad17514b55aef5dd Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 3 Aug 2026 08:58:50 -0500 Subject: [PATCH 09/10] feat: Update smoke test script and add electron mock for file manager extension --- package.json | 2 +- .../file-manager-extension/service.test.ts | 5 +-- .../smoke-electron-mock.cjs | 34 +++++++++++++++++++ src/core/electron/paths.ts | 8 ++++- 4 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 src/backend/features/file-manager-extension/smoke-electron-mock.cjs diff --git a/package.json b/package.json index 731f214c3..1df117921 100644 --- a/package.json +++ b/package.json @@ -35,7 +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 src/backend/features/file-manager-extension/file-manager-extension-smoke.ts", + "smoke:file-manager-extension": "NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true ts-node -r ./src/backend/features/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", diff --git a/src/backend/features/file-manager-extension/service.test.ts b/src/backend/features/file-manager-extension/service.test.ts index f66a8a197..29576899e 100644 --- a/src/backend/features/file-manager-extension/service.test.ts +++ b/src/backend/features/file-manager-extension/service.test.ts @@ -339,7 +339,6 @@ describe('service', () => { it('should use fs.cp for non-template assets in production mode', async () => { // Given process.env.NODE_ENV = 'production'; - PATHS.RESOURCES_PATH = '/tmp/resources'; detectAvailableFileManagerMock.mockResolvedValueOnce('nautilus'); doesFileExistMock.mockResolvedValue(false); @@ -365,7 +364,9 @@ describe('service', () => { // 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`), + expect.stringContaining( + `${PATHS.HOME_FOLDER_PATH}/.local/share/internxt-dolphin-extension/internxt-dolphin-actions.sh`, + ), 'utf8', ); expect(fsMock.chmod).toHaveBeenCalledWith( diff --git a/src/backend/features/file-manager-extension/smoke-electron-mock.cjs b/src/backend/features/file-manager-extension/smoke-electron-mock.cjs new file mode 100644 index 000000000..b7bb8caab --- /dev/null +++ b/src/backend/features/file-manager-extension/smoke-electron-mock.cjs @@ -0,0 +1,34 @@ +const Module = require('node:module'); +const os = require('node:os'); +const path = require('node:path'); + +const originalLoad = Module._load; + +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 || '/tmp'; + } + + 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); +}; diff --git a/src/core/electron/paths.ts b/src/core/electron/paths.ts index 89dc8e7f1..2b896fbe7 100644 --- a/src/core/electron/paths.ts +++ b/src/core/electron/paths.ts @@ -26,7 +26,13 @@ const RESOURCES_PATH = app.isPackaged 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_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 = { From 3f0032a0bfd5ff830be7ff3667457b3bc3843d4e Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 3 Aug 2026 09:12:33 -0500 Subject: [PATCH 10/10] feat: Update smoke test script to reference new electron mock file location --- package.json | 2 +- .../smoke}/file-manager-extension/smoke-electron-mock.cjs | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) rename {src/backend/features => tests/smoke}/file-manager-extension/smoke-electron-mock.cjs (68%) diff --git a/package.json b/package.json index 1df117921..a9a03a95c 100644 --- a/package.json +++ b/package.json @@ -35,7 +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 ./src/backend/features/file-manager-extension/smoke-electron-mock.cjs src/backend/features/file-manager-extension/file-manager-extension-smoke.ts", + "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", diff --git a/src/backend/features/file-manager-extension/smoke-electron-mock.cjs b/tests/smoke/file-manager-extension/smoke-electron-mock.cjs similarity index 68% rename from src/backend/features/file-manager-extension/smoke-electron-mock.cjs rename to tests/smoke/file-manager-extension/smoke-electron-mock.cjs index b7bb8caab..a73fb6ab1 100644 --- a/src/backend/features/file-manager-extension/smoke-electron-mock.cjs +++ b/tests/smoke/file-manager-extension/smoke-electron-mock.cjs @@ -1,8 +1,14 @@ 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') { @@ -14,7 +20,7 @@ function getPath(name) { } if (name === 'temp') { - return process.env.TMPDIR || '/tmp'; + return process.env.TMPDIR || workspaceTempDir; } return process.cwd();