diff --git a/README.md b/README.md index 31d100e8db..95c29da553 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ For the best experience with SSO authentication, we recommend using the .deb pac - [NVM](https://github.com/nvm-sh/nvm) (Node Version Manager) - Node.js 20 - If working on the FUSE daemon (Go), see [packages/fuse-daemon/README.md](packages/fuse-daemon/README.md) for Go and linting tool prerequisites. ### Install diff --git a/package-lock.json b/package-lock.json index 8b35baf72a..46e21f87d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24036,4 +24036,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 9a0eb5d24c..f94bcefdfe 100644 --- a/package.json +++ b/package.json @@ -214,11 +214,7 @@ "typeorm": "^0.3.26", "uuid": "^8.3.2" }, - "devEngines": { - "node": ">=20.0.0 <21.0.0", - "npm": ">=10.0.0 <11.0.0" - }, "engines": { "node": ">=20.0.0 <21.0.0" } -} +} \ No newline at end of file diff --git a/packages/fuse-daemon/cmd/daemon/main.go b/packages/fuse-daemon/cmd/daemon/main.go index c9e1b5be61..899d657653 100644 --- a/packages/fuse-daemon/cmd/daemon/main.go +++ b/packages/fuse-daemon/cmd/daemon/main.go @@ -28,7 +28,7 @@ func main() { logger.Info("fuse filesystem mounted", "mount", config.MountPoint) - if err := client.NotifyReady(logger); err != nil { + if err := client.NotifyReady(logger, config.BootID); err != nil { logger.Error("failed to notify electron of readiness", "error", err) if err := server.Unmount(); err != nil { logger.Error("failed to unmount fuse filesystem", "error", err) diff --git a/packages/fuse-daemon/internal/client/client.go b/packages/fuse-daemon/internal/client/client.go index 8ec2cb2027..1ca6f45297 100644 --- a/packages/fuse-daemon/internal/client/client.go +++ b/packages/fuse-daemon/internal/client/client.go @@ -20,6 +20,10 @@ type Client struct { socketPath string } +type notifyReadyRequest struct { + BootID string `json:"bootId"` +} + func NewClient(socketPath string) *Client { return &Client{ http: NewUnixSocketClient(socketPath), @@ -38,14 +42,20 @@ func NewUnixSocketClient(socketPath string) *http.Client { } // NotifyReady sends POST /daemon/ready to Electron to signal the daemon is up. -func (client *Client) NotifyReady(logger *slog.Logger) error { +func (client *Client) NotifyReady(logger *slog.Logger, bootID string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://localhost/daemon/ready", nil) + payload, err := json.Marshal(notifyReadyRequest{BootID: bootID}) + if err != nil { + return fmt.Errorf("marshalling ready payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://localhost/daemon/ready", bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("creating ready request: %w", err) } + req.Header.Set("Content-Type", "application/json") resp, err := client.http.Do(req) if err != nil { diff --git a/packages/fuse-daemon/internal/config/config.go b/packages/fuse-daemon/internal/config/config.go index 27f2a8d845..192cf6dacd 100644 --- a/packages/fuse-daemon/internal/config/config.go +++ b/packages/fuse-daemon/internal/config/config.go @@ -9,12 +9,14 @@ type Config struct { MountPoint string SocketPath string LogFile string + BootID string } func ParseConfig() Config { config := Config{ MountPoint: os.Getenv("INTERNXT_MOUNT"), SocketPath: os.Getenv("INTERNXT_SOCKET"), LogFile: os.Getenv("INTERNXT_LOG_FILE"), + BootID: os.Getenv("INTERNXT_BOOT_ID"), } var missing []string @@ -27,6 +29,9 @@ func ParseConfig() Config { if config.LogFile == "" { missing = append(missing, "INTERNXT_LOG_FILE") } + if config.BootID == "" { + missing = append(missing, "INTERNXT_BOOT_ID") + } if len(missing) > 0 { for _, envVar := range missing { diff --git a/src/apps/main/auth/service.test.ts b/src/apps/main/auth/service.test.ts index 13a14c6015..c3f606062e 100644 --- a/src/apps/main/auth/service.test.ts +++ b/src/apps/main/auth/service.test.ts @@ -89,6 +89,7 @@ describe('saveConfig and canHisConfigBeRestored', () => { backupsEnabled: true, backupInterval: 86_400_000, lastBackup: 1000, + virtualDriveRoot: '/home/user/Internxt Dirve/', syncRoot: '/home/user/Internxt', lastSavedListing: '', lastSync: -1, @@ -132,7 +133,8 @@ describe('saveConfig and canHisConfigBeRestored', () => { backupsEnabled: true, backupInterval: 86_400_000, lastBackup: 1000, - syncRoot: '/home/user/Internxt', + virtualDriveRoot: '/home/user/Internxt Drive/', + syncRoot: '/home/user/Internxt Drive', lastSavedListing: '', lastSync: -1, lastOnboardingShown: '2025-01-01', @@ -182,6 +184,7 @@ describe('saveConfig and canHisConfigBeRestored', () => { backupsEnabled: true, backupInterval: 86_400_000, lastBackup: 1000, + virtualDriveRoot: '/home/user/Internxt Drive/', syncRoot: '/home/user/Internxt', lastSavedListing: '', lastSync: -1, diff --git a/src/apps/main/config.ts b/src/apps/main/config.ts index 3142f9c4f8..d0aa0c4d24 100644 --- a/src/apps/main/config.ts +++ b/src/apps/main/config.ts @@ -18,6 +18,7 @@ const schema: Schema = { backgroundScanEnabled: { type: 'boolean' }, backupInterval: { type: 'number' }, lastBackup: { type: 'number' }, + virtualDriveRoot: { type: 'string' }, syncRoot: { type: 'string' }, lastSavedListing: { type: 'string' }, lastSync: { type: 'number' }, diff --git a/src/apps/main/config/save-config.ts b/src/apps/main/config/save-config.ts index e3c8e93338..1b0f2a765c 100644 --- a/src/apps/main/config/save-config.ts +++ b/src/apps/main/config/save-config.ts @@ -6,6 +6,7 @@ export const savedConfigFields = [ 'backgroundScanEnabled', 'backupInterval', 'lastBackup', + 'virtualDriveRoot', 'syncRoot', 'lastSavedListing', 'lastSync', @@ -25,6 +26,7 @@ export function saveConfig({ uuid }: { uuid: string }) { backgroundScanEnabled: ConfigStore.get('backgroundScanEnabled'), backupInterval: ConfigStore.get('backupInterval'), lastBackup: ConfigStore.get('lastBackup'), + virtualDriveRoot: ConfigStore.get('virtualDriveRoot'), syncRoot: ConfigStore.get('syncRoot'), lastSavedListing: ConfigStore.get('lastSavedListing'), lastSync: ConfigStore.get('lastSync'), diff --git a/src/apps/main/event-bus.ts b/src/apps/main/event-bus.ts index 8f5bced9c6..71a08e870b 100644 --- a/src/apps/main/event-bus.ts +++ b/src/apps/main/event-bus.ts @@ -10,7 +10,7 @@ interface Events { // in on app start and the tokens are correct USER_LOGGED_IN: () => void; - SYNC_ROOT_CHANGED: (newPath: string) => void; + SYNC_ROOT_CHANGED: ({ oldPath, newPath }: { oldPath: string; newPath: string }) => void; USER_LOGGED_OUT: () => void; diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 26a8d5cc9b..260428a777 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -148,6 +148,7 @@ export interface IElectronAPI { removeInfectedFiles: (infectedFiles: string[]) => Promise; cancelScan: () => Promise; }; + getVirtualDriveRoot(): Promise; chooseSyncRootWithDialog(): Promise; getBackupErrorByFolder(folderId: number): Promise; getLastBackupHadIssues(): Promise; diff --git a/src/apps/main/logging/setup-app-log-routing.ts b/src/apps/main/logging/setup-app-log-routing.ts index 9474694500..1c97ef3624 100644 --- a/src/apps/main/logging/setup-app-log-routing.ts +++ b/src/apps/main/logging/setup-app-log-routing.ts @@ -99,10 +99,16 @@ export function resolveAppLogFilePath({ logsPath, message }: Pops & { message?: return join(logsPath, DEFAULT_LOG_FILE_NAME); } +function getElectronLogModules(): ElectronLogModule[] { + return [coreElectronLog]; +} + export function setupAppLogRouting({ logsPath }: Pops) { - coreElectronLog.transports.file.resolvePathFn = (_, message) => { - return resolveAppLogFilePath({ logsPath, message }); - }; + for (const electronLog of getElectronLogModules()) { + electronLog.transports.file.resolvePathFn = (_, message) => { + return resolveAppLogFilePath({ logsPath, message }); + }; - coreElectronLog.transports.file.resolvePath = coreElectronLog.transports.file.resolvePathFn; + electronLog.transports.file.resolvePath = electronLog.transports.file.resolvePathFn; + } } diff --git a/src/apps/main/virtual-root-folder/service.test.ts b/src/apps/main/virtual-root-folder/service.test.ts new file mode 100644 index 0000000000..b031723d72 --- /dev/null +++ b/src/apps/main/virtual-root-folder/service.test.ts @@ -0,0 +1,102 @@ +import { dialog } from 'electron'; +import configStore from '../config'; +import eventBus from '../event-bus'; +import { chooseSyncRootWithDialog, getRootVirtualDrive } from './service'; + +vi.mock('electron', () => ({ + dialog: { + showOpenDialog: vi.fn(), + }, + shell: { + openPath: vi.fn(), + }, +})); + +vi.mock('../../../core/electron/paths', () => ({ + PATHS: { + ROOT_DRIVE_FOLDER: '/home/user/Internxt Drive', + }, +})); + +vi.mock('../config', () => ({ + default: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('../event-bus', () => ({ + default: { + emit: vi.fn(), + }, +})); + +vi.mock('../../shared/fs/ensure-folder-exists', () => ({ + ensureFolderExists: vi.fn(), +})); + +describe('service', () => { + const configGetMock = vi.mocked(configStore.get); + const configSetMock = vi.mocked(configStore.set); + const eventBusEmitMock = vi.mocked(eventBus.emit); + + beforeEach(() => { + const state: Record = { + virtualDriveRoot: '', + syncRoot: '', + lastSavedListing: '', + }; + + configGetMock.mockImplementation((key) => state[key]); + configSetMock.mockImplementation((key, value) => { + state[key] = value; + }); + }); + + it('should fallback to default root folder when no saved path exists', () => { + const state: Record = { + virtualDriveRoot: '', + syncRoot: '', + lastSavedListing: '', + }; + + configGetMock.mockImplementation((key) => { + return state[key]; + }); + configSetMock.mockImplementation((key, value) => { + state[key] = value; + }); + + const rootPath = getRootVirtualDrive(); + + expect(rootPath).toBe('/home/user/Internxt Drive/'); + expect(configSetMock).toHaveBeenCalledWith('virtualDriveRoot', '/home/user/'); + expect(configSetMock).toHaveBeenCalledWith('syncRoot', '/home/user/'); + }); + + it('should emit SYNC_ROOT_CHANGED with old and new paths when user picks a different folder', async () => { + const state: Record = { + virtualDriveRoot: '/old/root/', + syncRoot: '/old/root/', + lastSavedListing: '', + }; + + configGetMock.mockImplementation((key) => state[key]); + configSetMock.mockImplementation((key, value) => { + state[key] = value; + }); + + vi.mocked(dialog.showOpenDialog).mockResolvedValue({ + canceled: false, + filePaths: ['/new/root'], + } as Awaited>); + + const selectedPath = await chooseSyncRootWithDialog(); + + expect(selectedPath).toBe('/new/root/Internxt Drive/'); + expect(eventBusEmitMock).toHaveBeenCalledWith('SYNC_ROOT_CHANGED', { + oldPath: '/old/root/Internxt Drive/', + newPath: '/new/root/Internxt Drive/', + }); + }); +}); diff --git a/src/apps/main/virtual-root-folder/service.ts b/src/apps/main/virtual-root-folder/service.ts index 4f31ed8242..3f1dbfd3e7 100644 --- a/src/apps/main/virtual-root-folder/service.ts +++ b/src/apps/main/virtual-root-folder/service.ts @@ -8,6 +8,27 @@ import { ensureFolderExists } from '../../shared/fs/ensure-folder-exists'; import { PATHS } from '../../../core/electron/paths'; const VIRTUAL_DRIVE_FOLDER = PATHS.ROOT_DRIVE_FOLDER; +const VIRTUAL_DRIVE_FOLDER_NAME = 'Internxt Drive'; + +function normalizePathname(pathname: string) { + return pathname[pathname.length - 1] === path.sep ? pathname : pathname + path.sep; +} + +function getVirtualDriveMountPath(basePath: string) { + return path.join(basePath, VIRTUAL_DRIVE_FOLDER_NAME); +} + +function getBasePathFromMountPath(pathname: string) { + if (path.basename(path.resolve(pathname)) === VIRTUAL_DRIVE_FOLDER_NAME) { + return path.dirname(path.resolve(pathname)); + } + + return pathname; +} + +function getPathFromConfig() { + return configStore.get('virtualDriveRoot'); +} export async function clearDirectory(pathname: string): Promise { try { @@ -21,31 +42,58 @@ export async function clearDirectory(pathname: string): Promise { } export function setupRootFolder(pathname: string): void { - const pathNameWithSepInTheEnd = pathname[pathname.length - 1] === path.sep ? pathname : pathname + path.sep; + const pathNameWithSepInTheEnd = normalizePathname(pathname); + configStore.set('virtualDriveRoot', pathNameWithSepInTheEnd); + // Keep legacy key synchronized for older call paths still reading syncRoot. configStore.set('syncRoot', pathNameWithSepInTheEnd); configStore.set('lastSavedListing', ''); } export function getRootVirtualDrive(): string { - const current = configStore.get('syncRoot'); - ensureFolderExists(current); + const current = getPathFromConfig(); - if (current !== VIRTUAL_DRIVE_FOLDER) { - setupRootFolder(VIRTUAL_DRIVE_FOLDER); + if (current) { + const resolvedCurrent = path.resolve(current); + + if (path.basename(resolvedCurrent) === VIRTUAL_DRIVE_FOLDER_NAME) { + setupRootFolder(getBasePathFromMountPath(resolvedCurrent)); + ensureFolderExists(normalizePathname(resolvedCurrent)); + + return normalizePathname(resolvedCurrent); + } + + const mountPath = getVirtualDriveMountPath(resolvedCurrent); + ensureFolderExists(mountPath); + + return normalizePathname(mountPath); } - return configStore.get('syncRoot'); + const fallbackPath = getBasePathFromMountPath(VIRTUAL_DRIVE_FOLDER); + + setupRootFolder(fallbackPath); + + const rootPath = getPathFromConfig(); + const mountPath = getVirtualDriveMountPath(getBasePathFromMountPath(rootPath)); + ensureFolderExists(mountPath); + + return normalizePathname(mountPath); } export async function chooseSyncRootWithDialog(): Promise { + const previousPath = getRootVirtualDrive(); const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }); + if (!result.canceled) { const chosenPath = result.filePaths[0]; setupRootFolder(chosenPath); - eventBus.emit('SYNC_ROOT_CHANGED', chosenPath); + const nextPath = getRootVirtualDrive(); + + if (previousPath !== nextPath) { + eventBus.emit('SYNC_ROOT_CHANGED', { oldPath: previousPath, newPath: nextPath }); + } - return chosenPath; + return nextPath; } return null; diff --git a/src/apps/renderer/localize/locales/en.json b/src/apps/renderer/localize/locales/en.json index ec57078c4e..b244403089 100644 --- a/src/apps/renderer/localize/locales/en.json +++ b/src/apps/renderer/localize/locales/en.json @@ -154,6 +154,10 @@ "dark": "Dark" } }, + "virtual-drive-root": { + "label": "Internxt Folder", + "action": "Change" + }, "app-info": { "open-logs": "Open logs", "more": "Learn more about Internxt" diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json index f6cb17abfe..b81d29caf6 100644 --- a/src/apps/renderer/localize/locales/es.json +++ b/src/apps/renderer/localize/locales/es.json @@ -154,6 +154,10 @@ "dark": "Oscuro" } }, + "virtual-drive-root": { + "label": "Directorio de Internxt", + "action": "Cambiar" + }, "app-info": { "open-logs": "Abrir registros", "more": "Más información sobre Internxt" diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json index 5c26d57114..08bcb661c9 100644 --- a/src/apps/renderer/localize/locales/fr.json +++ b/src/apps/renderer/localize/locales/fr.json @@ -154,6 +154,10 @@ "dark": "Sombre" } }, + "virtual-drive-root": { + "label": "Dossier Internxt", + "action": "Changer" + }, "app-info": { "open-logs": "Ouvrir les registres", "more": "Plus d'informations sur Internxt" diff --git a/src/apps/renderer/pages/Settings/General/VirtualDriveRootPicker.test.tsx b/src/apps/renderer/pages/Settings/General/VirtualDriveRootPicker.test.tsx new file mode 100644 index 0000000000..34c1922b26 --- /dev/null +++ b/src/apps/renderer/pages/Settings/General/VirtualDriveRootPicker.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import VirtualDriveRootPicker from './VirtualDriveRootPicker'; + +vi.mock('../../../context/LocalContext', () => ({ + useTranslationContext: () => ({ translate: (key: string) => key }), +})); + +describe('VirtualDriveRootPicker', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.electron.getVirtualDriveRoot = vi.fn().mockResolvedValue('/old/root/Internxt Drive/'); + window.electron.chooseSyncRootWithDialog = vi.fn().mockResolvedValue('/new/root/'); + window.electron.logger.error = vi.fn(); + }); + + it('should render the current virtual drive root path', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('/old/root/Internxt Drive/')).toBeInTheDocument(); + }); + }); + + it('should refresh displayed path after changing the folder', async () => { + window.electron.getVirtualDriveRoot = vi + .fn() + .mockResolvedValueOnce('/old/root/Internxt Drive/') + .mockResolvedValueOnce('/new/root/Internxt Drive/'); + + render(); + + const changeFolderButton = await screen.findByRole('button', { + name: 'settings.general.virtual-drive-root.action', + }); + + fireEvent.click(changeFolderButton); + + await waitFor(() => { + expect(window.electron.chooseSyncRootWithDialog).toHaveBeenCalledOnce(); + expect(window.electron.getVirtualDriveRoot).toHaveBeenCalledTimes(2); + expect(screen.getByText('/new/root/Internxt Drive/')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/apps/renderer/pages/Settings/General/VirtualDriveRootPicker.tsx b/src/apps/renderer/pages/Settings/General/VirtualDriveRootPicker.tsx new file mode 100644 index 0000000000..ff051c2f76 --- /dev/null +++ b/src/apps/renderer/pages/Settings/General/VirtualDriveRootPicker.tsx @@ -0,0 +1,28 @@ +import Button from '../../../components/Button'; +import { useTranslationContext } from '../../../context/LocalContext'; +import useVirtualDriveRootPicker from './useVirtualDriveRootPicker'; + +export default function VirtualDriveRootPicker() { + const { translate } = useTranslationContext(); + const { rootPath, isUpdating, onChooseFolder } = useVirtualDriveRootPicker(); + + return ( +
+

+ {translate('settings.general.virtual-drive-root.label')} +

+ +
+
+

+ {rootPath} +

+
+ + +
+
+ ); +} diff --git a/src/apps/renderer/pages/Settings/General/index.tsx b/src/apps/renderer/pages/Settings/General/index.tsx index 1f1dd71673..199b5eb71b 100644 --- a/src/apps/renderer/pages/Settings/General/index.tsx +++ b/src/apps/renderer/pages/Settings/General/index.tsx @@ -3,6 +3,7 @@ import DeviceName from './DeviceName'; import LanguagePicker from './LanguagePicker'; import ThemePicker from './ThemePicker'; import StartAutomatically from './StartAutomatically'; +import VirtualDriveRootPicker from './VirtualDriveRootPicker'; export default function GeneralSection({ active }: { active: boolean }) { return ( @@ -11,11 +12,11 @@ export default function GeneralSection({ active }: { active: boolean }) {
-
+
diff --git a/src/apps/renderer/pages/Settings/General/useVirtualDriveRootPicker.ts b/src/apps/renderer/pages/Settings/General/useVirtualDriveRootPicker.ts new file mode 100644 index 0000000000..900f5e6032 --- /dev/null +++ b/src/apps/renderer/pages/Settings/General/useVirtualDriveRootPicker.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; + +export default function useVirtualDriveRootPicker() { + const [rootPath, setRootPath] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + + async function refreshRootPath() { + const currentRootPath = await window.electron.getVirtualDriveRoot(); + setRootPath(currentRootPath); + } + + async function onChooseFolder() { + setIsUpdating(true); + + try { + const selectedPath = await window.electron.chooseSyncRootWithDialog(); + if (!selectedPath) { + return; + } + + await refreshRootPath(); + } catch (error) { + window.electron.logger.error({ + msg: '[SETTINGS][GENERAL] Failed to update virtual drive root folder', + error, + }); + } finally { + setIsUpdating(false); + } + } + + useEffect(() => { + void refreshRootPath(); + }, []); + + return { + rootPath, + isUpdating, + onChooseFolder, + }; +} diff --git a/src/backend/features/virtual-drive/controllers/daemon.controller.test.ts b/src/backend/features/virtual-drive/controllers/daemon.controller.test.ts index 2bdc4b09be..0cf8147b35 100644 --- a/src/backend/features/virtual-drive/controllers/daemon.controller.test.ts +++ b/src/backend/features/virtual-drive/controllers/daemon.controller.test.ts @@ -9,11 +9,12 @@ describe('daemonReadyController', () => { it('should resolve the daemon ready signal and return 200', () => { const req = mockDeep(); + req.body = { bootId: 'boot-id-1' }; const res = mockDeep(); daemonReadyController(req, res); - expect(resolveDaemonReadyMock).toHaveBeenCalledOnce(); + expect(resolveDaemonReadyMock).toHaveBeenCalledWith({ bootId: 'boot-id-1' }); expect(res.sendStatus).toHaveBeenCalledWith(200); }); }); diff --git a/src/backend/features/virtual-drive/controllers/daemon.controller.ts b/src/backend/features/virtual-drive/controllers/daemon.controller.ts index cd4f5f4fda..c8d4efa079 100644 --- a/src/backend/features/virtual-drive/controllers/daemon.controller.ts +++ b/src/backend/features/virtual-drive/controllers/daemon.controller.ts @@ -2,8 +2,12 @@ import { Request, Response } from 'express'; import { logger } from '@internxt/drive-desktop-core/build/backend'; import { resolveDaemonReady } from '../services/daemon.service'; -export function daemonReadyController(_: Request, res: Response): void { +type DaemonReadyBody = { + bootId: string; +}; + +export function daemonReadyController(req: Request, res: Response): void { logger.debug({ msg: '[FUSE DAEMON] daemon ready signal received' }); - resolveDaemonReady(); + resolveDaemonReady({ bootId: req.body.bootId }); res.sendStatus(200); } diff --git a/src/backend/features/virtual-drive/ipc/handlers.ts b/src/backend/features/virtual-drive/ipc/handlers.ts index 75d94701d8..121b09ae61 100644 --- a/src/backend/features/virtual-drive/ipc/handlers.ts +++ b/src/backend/features/virtual-drive/ipc/handlers.ts @@ -1,6 +1,10 @@ import { ipcMain } from 'electron'; import eventBus from '../../../../apps/main/event-bus'; -import { getVirtualDriveContainer, startVirtualDrive } from '../services/virtual-drive.service'; +import { + getVirtualDriveContainer, + startVirtualDrive, + remountVirtualDriveOnRootChange, +} from '../services/drive-folder/virtual-drive.service'; import { updateVirtualDriveContainer } from '../services/update-virtual-drive-container.service'; import { DependencyInjectionUserProvider } from '../../../../apps/shared/dependency-injection/DependencyInjectionUserProvider'; import { logger } from '@internxt/drive-desktop-core/build/backend'; @@ -15,8 +19,13 @@ function remoteChangesSyncedHandler() { } } +function syncRootChangedHandler({ oldPath, newPath }: { oldPath: string; newPath: string }) { + void remountVirtualDriveOnRootChange({ oldPath, newPath }); +} + export function registerVirtualDriveHandlers() { eventBus.on('INITIAL_SYNC_READY', startVirtualDrive); eventBus.on('REMOTE_CHANGES_SYNCHED', remoteChangesSyncedHandler); + eventBus.on('SYNC_ROOT_CHANGED', syncRootChangedHandler); ipcMain.handle('get-virtual-drive-status', getVirtualDriveState); } diff --git a/src/backend/features/virtual-drive/routes/operations.routes.test.ts b/src/backend/features/virtual-drive/routes/operations.routes.test.ts index d0b3ad3456..0e0c0c7b03 100644 --- a/src/backend/features/virtual-drive/routes/operations.routes.test.ts +++ b/src/backend/features/virtual-drive/routes/operations.routes.test.ts @@ -34,4 +34,20 @@ describe('buildOperationsRouter', () => { it('should register POST /release', () => { expect(routes).toContain(OPERATION_PATHS.RELEASE); }); + + it('should register POST /open', () => { + expect(routes).toContain(OPERATION_PATHS.OPEN); + }); + + it('should register POST /opendir', () => { + expect(routes).toContain(OPERATION_PATHS.OPEN_DIR); + }); + + it('should register POST /read', () => { + expect(routes).toContain(OPERATION_PATHS.READ); + }); + + it('should register POST /release', () => { + expect(routes).toContain(OPERATION_PATHS.RELEASE); + }); }); diff --git a/src/backend/features/virtual-drive/services/daemon.service.test.ts b/src/backend/features/virtual-drive/services/daemon.service.test.ts index 2bbbe7bd73..7f777ca399 100644 --- a/src/backend/features/virtual-drive/services/daemon.service.test.ts +++ b/src/backend/features/virtual-drive/services/daemon.service.test.ts @@ -1,17 +1,37 @@ import { EventEmitter } from 'node:events'; import { spawn } from 'node:child_process'; -import { resolveDaemonReady, daemonReady, stopDaemon, startDaemon } from './daemon.service'; +import { resolveDaemonReady, stopDaemon, startDaemon } from './daemon.service'; vi.mock('node:child_process', () => ({ spawn: vi.fn(), })); +function getBootIdFromSpawnCall() { + const latestCall = vi.mocked(spawn).mock.calls.at(-1); + const options = latestCall?.[2] as { env?: Record } | undefined; + + return options?.env?.INTERNXT_BOOT_ID; +} + describe('daemon.service', () => { describe('resolveDaemonReady', () => { - it('should resolve the daemonReady promise', async () => { - resolveDaemonReady(); + it('should resolve the active startDaemon promise', async () => { + const fakeDaemon = Object.assign(new EventEmitter(), { + kill: vi.fn(), + stderr: new EventEmitter(), + }); + vi.mocked(spawn).mockReturnValue(fakeDaemon as unknown as ReturnType); + + const startPromise = startDaemon('/mock/mount'); + + const bootId = getBootIdFromSpawnCall(); + resolveDaemonReady({ bootId: bootId ?? '' }); + + await expect(startPromise).resolves.toBeUndefined(); - await expect(daemonReady).resolves.toBeUndefined(); + const stopPromise = stopDaemon(); + fakeDaemon.emit('exit', 0); + await stopPromise; }); }); @@ -52,6 +72,27 @@ describe('daemon.service', () => { await expect(startPromise).rejects.toThrow('fuse daemon exited before ready with code 1'); }); + + it('should require a new ready signal for each restart', async () => { + const firstStart = startDaemon('/mock/mount'); + const firstBootId = getBootIdFromSpawnCall(); + resolveDaemonReady({ bootId: firstBootId ?? '' }); + await expect(firstStart).resolves.toBeUndefined(); + + const secondStart = startDaemon('/mock/mount-2'); + + let secondResolved = false; + void secondStart.then(() => { + secondResolved = true; + }); + + await Promise.resolve(); + expect(secondResolved).toBe(false); + + const secondBootId = getBootIdFromSpawnCall(); + resolveDaemonReady({ bootId: secondBootId ?? '' }); + await expect(secondStart).resolves.toBeUndefined(); + }); }); describe('stopDaemon', () => { diff --git a/src/backend/features/virtual-drive/services/daemon.service.ts b/src/backend/features/virtual-drive/services/daemon.service.ts index d551f9c076..5a4c465afe 100644 --- a/src/backend/features/virtual-drive/services/daemon.service.ts +++ b/src/backend/features/virtual-drive/services/daemon.service.ts @@ -1,20 +1,37 @@ import { spawn, ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { logger } from '@internxt/drive-desktop-core/build/backend'; import { PATHS } from '../../../../core/electron/paths'; import { FuseDriveStatus } from '../FuseDriveStatus'; import { broadcastToWindows } from '../../../../apps/main/windows'; -let resolveReady: () => void; +type DaemonReadyState = { + bootId: string; + resolve: () => void; +}; + +let daemonReadyState: DaemonReadyState | undefined; let daemon: ChildProcess | null = null; let status: FuseDriveStatus = 'UNMOUNTED'; const SIGKILL_TIMEOUT_MS = 5_000; -export const daemonReady = new Promise((resolve) => { - resolveReady = resolve; -}); +export function resolveDaemonReady({ bootId }: { bootId: string }): void { + if (!daemonReadyState) { + logger.warn({ msg: '[FUSE DAEMON] received ready signal before daemon startup' }); + return; + } + + if (bootId !== daemonReadyState.bootId) { + logger.warn({ + msg: '[FUSE DAEMON] ignored ready signal with stale boot id', + bootId, + activeBootId: daemonReadyState.bootId, + }); + return; + } -export function resolveDaemonReady(): void { - resolveReady(); + daemonReadyState.resolve(); + daemonReadyState = undefined; } export function getVirtualDriveState(): FuseDriveStatus { @@ -22,12 +39,19 @@ export function getVirtualDriveState(): FuseDriveStatus { } export function startDaemon(mountPoint: string): Promise { + const bootId = randomUUID(); + + const daemonReady = new Promise((resolve) => { + daemonReadyState = { bootId, resolve }; + }); + const spawnedDaemon = spawn(PATHS.FUSE_DAEMON_BINARY, [], { env: { ...process.env, INTERNXT_MOUNT: mountPoint, INTERNXT_SOCKET: PATHS.FUSE_DAEMON_SOCKET, INTERNXT_LOG_FILE: PATHS.FUSE_DAEMON_LOG, + INTERNXT_BOOT_ID: bootId, }, }); @@ -44,6 +68,11 @@ export function startDaemon(mountPoint: string): Promise { } else { status = 'UNMOUNTED'; } + + if (daemonReadyState?.bootId === bootId) { + daemonReadyState = undefined; + } + daemon = null; }); diff --git a/src/backend/features/virtual-drive/services/drive-folder/remount-virtual-drive.test.ts b/src/backend/features/virtual-drive/services/drive-folder/remount-virtual-drive.test.ts new file mode 100644 index 0000000000..4260f11d3d --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/remount-virtual-drive.test.ts @@ -0,0 +1,67 @@ +import * as virtualDriveServiceModule from './virtual-drive.service'; +import * as removePreviousRootFolderModule from './remove-previous-root-folder'; +import { remountVirtualDrive } from './remount-virtual-drive'; +import { partialSpyOn, calls, call } from '../../../../../../tests/vitest/utils.helper'; + +describe('remount-virtual-drive', () => { + const stopVirtualDriveOnce = partialSpyOn(virtualDriveServiceModule, 'stopVirtualDriveOnce'); + const startVirtualDrive = partialSpyOn(virtualDriveServiceModule, 'startVirtualDrive'); + const removePreviousRootFolder = partialSpyOn(removePreviousRootFolderModule, 'removePreviousRootFolder'); + + type Props = Parameters[0]; + + beforeEach(() => { + stopVirtualDriveOnce.mockResolvedValue(undefined); + startVirtualDrive.mockResolvedValue(undefined); + removePreviousRootFolder.mockResolvedValue(undefined); + }); + + it('skips remount when oldPath and newPath are the same', async () => { + // Given + const props: Props = { oldPath: '/same/path/', newPath: '/same/path/' }; + + // When + await remountVirtualDrive(props); + + // Then + calls(stopVirtualDriveOnce).toHaveLength(0); + calls(startVirtualDrive).toHaveLength(0); + }); + + it('stops the drive before removing the old folder', async () => { + // Given + const props: Props = { oldPath: '/old/root/', newPath: '/new/root/' }; + + // When + await remountVirtualDrive(props); + + // Then + expect(stopVirtualDriveOnce.mock.invocationCallOrder[0]).toBeLessThan( + removePreviousRootFolder.mock.invocationCallOrder[0], + ); + }); + + it('removes the old folder before starting the drive', async () => { + // Given + const props: Props = { oldPath: '/old/root/', newPath: '/new/root/' }; + + // When + await remountVirtualDrive(props); + + // Then + expect(removePreviousRootFolder.mock.invocationCallOrder[0]).toBeLessThan( + startVirtualDrive.mock.invocationCallOrder[0], + ); + }); + + it('passes oldPath and newPath to removePreviousRootFolder', async () => { + // Given + const props: Props = { oldPath: '/old/root/', newPath: '/new/root/' }; + + // When + await remountVirtualDrive(props); + + // Then + call(removePreviousRootFolder).toMatchObject({ oldPath: '/old/root/', newPath: '/new/root/' }); + }); +}); diff --git a/src/backend/features/virtual-drive/services/drive-folder/remount-virtual-drive.ts b/src/backend/features/virtual-drive/services/drive-folder/remount-virtual-drive.ts new file mode 100644 index 0000000000..c285d66a2f --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/remount-virtual-drive.ts @@ -0,0 +1,22 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { stopVirtualDriveOnce } from './virtual-drive.service'; +import { removePreviousRootFolder } from './remove-previous-root-folder'; +import { startVirtualDrive } from './virtual-drive.service'; + +type Props = { + oldPath: string; + newPath: string; +}; + +export async function remountVirtualDrive({ oldPath, newPath }: Props) { + if (oldPath === newPath) { + logger.debug({ msg: '[VIRTUAL DRIVE] mount location unchanged, skipping remount', oldPath, newPath }); + return; + } + + logger.debug({ msg: '[VIRTUAL DRIVE] remounting due to root folder change', oldPath, newPath }); + await stopVirtualDriveOnce(); + await removePreviousRootFolder({ oldPath, newPath }); + await startVirtualDrive(); + logger.debug({ msg: '[VIRTUAL DRIVE] remounted with new root folder', oldPath, newPath }); +} diff --git a/src/backend/features/virtual-drive/services/drive-folder/remove-previous-root-folder.test.ts b/src/backend/features/virtual-drive/services/drive-folder/remove-previous-root-folder.test.ts new file mode 100644 index 0000000000..565f5b0f51 --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/remove-previous-root-folder.test.ts @@ -0,0 +1,116 @@ +import { execFile } from 'node:child_process'; +import { rm } from 'node:fs/promises'; +import { removePreviousRootFolder } from './remove-previous-root-folder'; +import { calls, call } from '../../../../../../tests/vitest/utils.helper'; + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + rm: vi.fn(), +})); + +type ExecFileCb = (err: Error | null) => void; + +describe('remove-previous-root-folder', () => { + const execFileMock = vi.mocked(execFile); + const rmMock = vi.mocked(rm); + + type Props = Parameters[0]; + + beforeEach(() => { + rmMock.mockResolvedValue(undefined); + execFileMock.mockImplementation(((_cmd: string, _args: string[], cb: ExecFileCb) => + cb(null)) as unknown as typeof execFile); + }); + + it('returns early if oldPath is empty', async () => { + // Given + const props: Props = { oldPath: ' ', newPath: '/new/path' }; + + // When + await removePreviousRootFolder(props); + + // Then + calls(rmMock).toHaveLength(0); + }); + + it('returns early if oldPath resolves to the same as newPath', async () => { + // Given + const props: Props = { oldPath: '/same/path/', newPath: '/same/path/' }; + + // When + await removePreviousRootFolder(props); + + // Then + calls(rmMock).toHaveLength(0); + }); + + it('skips deletion if oldPath is the root folder', async () => { + // Given + const props: Props = { oldPath: '/', newPath: '/new/path' }; + + // When + await removePreviousRootFolder(props); + + // Then + calls(rmMock).toHaveLength(0); + }); + + it('skips deletion if oldPath is the home folder', async () => { + // Given — home is /mock/home per global electron mock in vitest.setup.main.ts + const props: Props = { oldPath: '/mock/home', newPath: '/new/path' }; + + // When + await removePreviousRootFolder(props); + + // Then + calls(rmMock).toHaveLength(0); + }); + + it('releases stale fuse mount before removing old folder', async () => { + // Given + const props: Props = { oldPath: '/old/path', newPath: '/new/path' }; + + // When + await removePreviousRootFolder(props); + + // Then + call(execFileMock).toMatchObject(['fusermount3', ['-uz', '/old/path'], expect.any(Function)]); + expect(execFileMock.mock.invocationCallOrder[0]).toBeLessThan(rmMock.mock.invocationCallOrder[0]); + }); + + it('removes the old folder with the correct options', async () => { + // Given + const props: Props = { oldPath: '/old/path', newPath: '/new/path' }; + + // When + await removePreviousRootFolder(props); + + // Then + call(rmMock).toStrictEqual(['/old/path', { recursive: true, force: true }]); + }); + + it('continues if fusermount3 fails', async () => { + // Given + const props: Props = { oldPath: '/old/path', newPath: '/new/path' }; + execFileMock.mockImplementationOnce(((_cmd: string, _args: string[], cb: ExecFileCb) => + cb(new Error('not a fuse mount'))) as unknown as typeof execFile); + + // When + await removePreviousRootFolder(props); + + // Then — rm should still be called + calls(rmMock).toHaveLength(1); + }); + + it('continues and does not throw if rm fails', async () => { + // Given + const props: Props = { oldPath: '/old/path', newPath: '/new/path' }; + rmMock.mockRejectedValue(new Error('disk error')); + + // When + Then + await expect(removePreviousRootFolder(props)).resolves.toBeUndefined(); + }); +}); diff --git a/src/backend/features/virtual-drive/services/drive-folder/remove-previous-root-folder.ts b/src/backend/features/virtual-drive/services/drive-folder/remove-previous-root-folder.ts new file mode 100644 index 0000000000..53e3ec1f6d --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/remove-previous-root-folder.ts @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { resolve } from 'node:path'; +import { rm } from 'node:fs/promises'; +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { PATHS } from '../../../../../core/electron/paths'; + +const execFileAsync = promisify(execFile); + +/** + * Releases a stale FUSE mount at the given path using a lazy unmount. + * The daemon may have exited without cleanly unmounting (e.g. open file handles), + * leaving the kernel mount entry in a disconnected (ENOTCONN) state. + * fusermount3 -uz detaches the mount even when the filesystem is busy. + */ +async function releaseStaleFuseMount(mountPath: string): Promise { + try { + await execFileAsync('fusermount3', ['-uz', mountPath]); + logger.debug({ msg: '[VIRTUAL DRIVE] stale fuse mount released', mountPath }); + } catch { + // Not a FUSE mount point or already unmounted — proceed. + } +} + +type Props = { + oldPath: string; + newPath: string; +}; + +export async function removePreviousRootFolder({ oldPath, newPath }: Props) { + const oldPathSafe = oldPath.trim(); + if (!oldPathSafe) return; + + const resolvedOldPath = resolve(oldPathSafe); + const resolvedNewPath = resolve(newPath); + const resolvedHomePath = resolve(PATHS.HOME_FOLDER_PATH); + + if (resolvedOldPath === resolvedNewPath) return; + + if (resolvedOldPath === '/' || resolvedOldPath === resolvedHomePath) { + logger.warn({ msg: '[VIRTUAL DRIVE] skipping previous root folder deletion due to unsafe path', oldPath }); + return; + } + + await releaseStaleFuseMount(resolvedOldPath); + + try { + await rm(resolvedOldPath, { recursive: true, force: true }); + logger.debug({ msg: '[VIRTUAL DRIVE] previous root folder removed', oldPath: resolvedOldPath }); + } catch (error) { + logger.error({ msg: '[VIRTUAL DRIVE] failed removing previous root folder', error, oldPath: resolvedOldPath }); + } +} diff --git a/src/backend/features/virtual-drive/services/drive-folder/stop-virual-drive.test.ts b/src/backend/features/virtual-drive/services/drive-folder/stop-virual-drive.test.ts new file mode 100644 index 0000000000..9ad12f9857 --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/stop-virual-drive.test.ts @@ -0,0 +1,65 @@ +import type { Container } from 'diod'; +import * as daemonServiceModule from '../daemon.service'; +import * as serverServiceModule from '../server.service'; +import * as hydrationStateModule from '../../../fuse/on-read/download-cache/hydration-state'; +import { stopVirtualDrive } from './stop-virual-drive'; +import { partialSpyOn, calls } from '../../../../../../tests/vitest/utils.helper'; + +describe('stop-virual-drive', () => { + const stopDaemon = partialSpyOn(daemonServiceModule, 'stopDaemon'); + const stopFuseDaemonServer = partialSpyOn(serverServiceModule, 'stopFuseDaemonServer'); + const abortAllHydrations = partialSpyOn(hydrationStateModule, 'abortAllHydrations'); + const clearHydrationState = partialSpyOn(hydrationStateModule, 'clearHydrationState'); + + const deleteAll = vi.fn(); + const container = { + get: vi.fn(() => ({ deleteAll })), + } as unknown as Container; + + type Props = Parameters[0]; + const props: Props = { container: undefined }; + + beforeEach(() => { + stopDaemon.mockResolvedValue(undefined); + stopFuseDaemonServer.mockResolvedValue(undefined); + deleteAll.mockResolvedValue(undefined); + }); + + it('aborts active hydrations before clearing hydration state', async () => { + // When + await stopVirtualDrive(props); + + // Then + calls(abortAllHydrations).toHaveLength(1); + calls(clearHydrationState).toHaveLength(1); + expect(abortAllHydrations.mock.invocationCallOrder[0]).toBeLessThan( + clearHydrationState.mock.invocationCallOrder[0], + ); + }); + + it('stops daemon before stopping server', async () => { + // When + await stopVirtualDrive(props); + + // Then + calls(stopDaemon).toHaveLength(1); + calls(stopFuseDaemonServer).toHaveLength(1); + expect(stopDaemon.mock.invocationCallOrder[0]).toBeLessThan(stopFuseDaemonServer.mock.invocationCallOrder[0]); + }); + + it('clears storage when container is provided', async () => { + // When + await stopVirtualDrive({ container }); + + // Then + expect(deleteAll).toHaveBeenCalledOnce(); + }); + + it('skips storage clear when no container is provided', async () => { + // When + await stopVirtualDrive({ container: undefined }); + + // Then + expect(deleteAll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/features/virtual-drive/services/drive-folder/stop-virual-drive.ts b/src/backend/features/virtual-drive/services/drive-folder/stop-virual-drive.ts new file mode 100644 index 0000000000..319abb0bce --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/stop-virual-drive.ts @@ -0,0 +1,21 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { stopDaemon } from '../daemon.service'; +import { stopFuseDaemonServer } from '../server.service'; +import { abortAllHydrations, clearHydrationState } from '../../../fuse/on-read/download-cache/hydration-state'; +import { StorageFilesRepository } from '../../../../../context/storage/StorageFiles/domain/StorageFilesRepository'; +import { Container } from 'diod'; + +export async function stopVirtualDrive({ container }: { container?: Container }) { + logger.debug({ msg: '[VIRTUAL DRIVE] stopping daemon...' }); + abortAllHydrations(); + await stopDaemon(); + logger.debug({ msg: '[VIRTUAL DRIVE] clearing storage cache...' }); + clearHydrationState(); + if (container) { + await container.get(StorageFilesRepository).deleteAll(); + } + logger.debug({ msg: '[VIRTUAL DRIVE] stopping server...' }); + await stopFuseDaemonServer(); + container = undefined; + logger.debug({ msg: '[VIRTUAL DRIVE] stopped' }); +} diff --git a/src/backend/features/virtual-drive/services/drive-folder/virtual-drive.service.test.ts b/src/backend/features/virtual-drive/services/drive-folder/virtual-drive.service.test.ts new file mode 100644 index 0000000000..c690f350e0 --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/virtual-drive.service.test.ts @@ -0,0 +1,113 @@ +import type { Container } from 'diod'; +import { DriveDependencyContainerFactory } from '../../../../../apps/drive/dependency-injection/DriveDependencyContainerFactory'; +import { DependencyInjectionUserProvider } from '../../../../../apps/shared/dependency-injection/DependencyInjectionUserProvider'; +import * as stopVirtualDriveModule from './stop-virual-drive'; +import * as remountVirtualDriveModule from './remount-virtual-drive'; +import * as daemonServiceModule from '../daemon.service'; +import * as serverServiceModule from '../server.service'; +import * as hydrationStateModule from '../../../fuse/on-read/download-cache/hydration-state'; +import * as virtualRootFolderModule from '../../../../../apps/main/virtual-root-folder/service'; +import * as updateVirtualDriveContainerModule from '../update-virtual-drive-container.service'; +import { startVirtualDrive, stopVirtualDriveOnce, remountVirtualDriveOnRootChange } from './virtual-drive.service'; +import { partialSpyOn, calls, call } from '../../../../../../tests/vitest/utils.helper'; + +describe('virtual-drive.service', () => { + const stopVirtualDrive = partialSpyOn(stopVirtualDriveModule, 'stopVirtualDrive'); + const remountVirtualDrive = partialSpyOn(remountVirtualDriveModule, 'remountVirtualDrive'); + const startDaemon = partialSpyOn(daemonServiceModule, 'startDaemon'); + const startFuseDaemonServer = partialSpyOn(serverServiceModule, 'startFuseDaemonServer'); + const clearHydrationState = partialSpyOn(hydrationStateModule, 'clearHydrationState'); + const getRootVirtualDrive = partialSpyOn(virtualRootFolderModule, 'getRootVirtualDrive'); + const updateVirtualDriveContainer = partialSpyOn(updateVirtualDriveContainerModule, 'updateVirtualDriveContainer'); + const buildContainer = partialSpyOn(DriveDependencyContainerFactory, 'build'); + const getUser = partialSpyOn(DependencyInjectionUserProvider, 'get'); + + const deleteAll = vi.fn(); + const containerMock = { + get: vi.fn(() => ({ deleteAll })), + } as unknown as Container; + + beforeEach(() => { + stopVirtualDrive.mockResolvedValue(undefined); + remountVirtualDrive.mockResolvedValue(undefined); + startDaemon.mockResolvedValue(undefined); + startFuseDaemonServer.mockResolvedValue(undefined); + getRootVirtualDrive.mockReturnValue('/mock/root/'); + getUser.mockReturnValue({} as never); + updateVirtualDriveContainer.mockResolvedValue({}); + buildContainer.mockResolvedValue(containerMock); + deleteAll.mockResolvedValue(undefined); + }); + + describe('startVirtualDrive', () => { + it('builds container and starts server and daemon', async () => { + // When + await startVirtualDrive(); + + // Then + calls(buildContainer).toHaveLength(1); + calls(startFuseDaemonServer).toHaveLength(1); + calls(startDaemon).toHaveLength(1); + }); + + it('clears hydration state before starting daemon', async () => { + // When + await startVirtualDrive(); + + // Then + expect(clearHydrationState.mock.invocationCallOrder[0]).toBeLessThan(startDaemon.mock.invocationCallOrder[0]); + }); + + it('starts daemon with the virtual drive root path', async () => { + // When + await startVirtualDrive(); + + // Then + call(startDaemon).toBe('/mock/root/'); + }); + }); + + describe('stopVirtualDriveOnce', () => { + it('shares in-flight stop when called twice concurrently', async () => { + // Given + let resolveStop: () => void; + stopVirtualDrive.mockReturnValueOnce( + new Promise((resolve) => { + resolveStop = resolve; + }), + ); + + // When + const first = stopVirtualDriveOnce(); + const second = stopVirtualDriveOnce(); + + // Then + calls(stopVirtualDrive).toHaveLength(1); + + resolveStop!(); + await Promise.all([first, second]); + }); + }); + + describe('remountVirtualDriveOnRootChange', () => { + it('shares in-flight remount when called twice concurrently', async () => { + // Given + let resolveRemount: () => void; + remountVirtualDrive.mockReturnValueOnce( + new Promise((resolve) => { + resolveRemount = resolve; + }), + ); + + // When + const first = remountVirtualDriveOnRootChange({ oldPath: '/old/', newPath: '/new/' }); + const second = remountVirtualDriveOnRootChange({ oldPath: '/old/', newPath: '/new/' }); + + // Then + calls(remountVirtualDrive).toHaveLength(1); + + resolveRemount!(); + await Promise.all([first, second]); + }); + }); +}); diff --git a/src/backend/features/virtual-drive/services/drive-folder/virtual-drive.service.ts b/src/backend/features/virtual-drive/services/drive-folder/virtual-drive.service.ts new file mode 100644 index 0000000000..72ee1b23ca --- /dev/null +++ b/src/backend/features/virtual-drive/services/drive-folder/virtual-drive.service.ts @@ -0,0 +1,59 @@ +import { Container } from 'diod'; +import { DriveDependencyContainerFactory } from '../../../../../apps/drive/dependency-injection/DriveDependencyContainerFactory'; +import { getRootVirtualDrive } from '../../../../../apps/main/virtual-root-folder/service'; +import { startDaemon } from '../daemon.service'; +import { startFuseDaemonServer } from '../server.service'; +import { updateVirtualDriveContainer } from '../update-virtual-drive-container.service'; +import { DependencyInjectionUserProvider } from '../../../../../apps/shared/dependency-injection/DependencyInjectionUserProvider'; +import { clearHydrationState } from '../../../fuse/on-read/download-cache/hydration-state'; +import { StorageFilesRepository } from '../../../../../context/storage/StorageFiles/domain/StorageFilesRepository'; +import { remountVirtualDrive } from './remount-virtual-drive'; +import { stopVirtualDrive } from './stop-virual-drive'; + +let container: Container | undefined; +let stopInFlight: Promise | undefined; +let remountInFlight: Promise | undefined; + +export function getVirtualDriveContainer(): Container | undefined { + return container; +} + +export async function startVirtualDrive() { + const localRoot = getRootVirtualDrive(); + container = await DriveDependencyContainerFactory.build(); + await updateVirtualDriveContainer({ container, user: DependencyInjectionUserProvider.get() }); + /** + * Clear stale block-cache state and orphaned hydrated files before mounting. + * Future virtual-drive reads recreate cache files and hydrate only requested blocks. + */ + clearHydrationState(); + await container.get(StorageFilesRepository).deleteAll(); + await startFuseDaemonServer(container); + await startDaemon(localRoot); +} + +export async function stopVirtualDriveOnce() { + if (stopInFlight) { + return stopInFlight; + } + + stopInFlight = stopVirtualDrive({ container }); + + try { + await stopInFlight; + } finally { + stopInFlight = undefined; + } +} + +export async function remountVirtualDriveOnRootChange({ oldPath, newPath }: { oldPath: string; newPath: string }) { + if (remountInFlight) return remountInFlight; + + remountInFlight = remountVirtualDrive({ oldPath, newPath }); + + try { + await remountInFlight; + } finally { + remountInFlight = undefined; + } +} diff --git a/src/backend/features/virtual-drive/services/virtual-drive.service.test.ts b/src/backend/features/virtual-drive/services/virtual-drive.service.test.ts deleted file mode 100644 index 7fed05b2f1..0000000000 --- a/src/backend/features/virtual-drive/services/virtual-drive.service.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { stopDaemon } from './daemon.service'; -import { stopFuseDaemonServer } from './server.service'; -import { abortAllHydrations, clearHydrationState } from '../../fuse/on-read/download-cache/hydration-state'; -import { stopVirtualDrive } from './virtual-drive.service'; -vi.mock('./daemon.service', () => ({ - startDaemon: vi.fn(), - stopDaemon: vi.fn(), -})); - -vi.mock('./server.service', () => ({ - startFuseDaemonServer: vi.fn(), - stopFuseDaemonServer: vi.fn(), -})); - -vi.mock('../../fuse/on-read/download-cache/hydration-state', () => ({ - abortAllHydrations: vi.fn(), - clearHydrationState: vi.fn(), -})); - -const stopDaemonMock = vi.mocked(stopDaemon); -const stopFuseDaemonServerMock = vi.mocked(stopFuseDaemonServer); -const abortAllHydrationsMock = vi.mocked(abortAllHydrations); -const clearHydrationStateMock = vi.mocked(clearHydrationState); - -describe('stopVirtualDrive', () => { - beforeEach(() => { - vi.clearAllMocks(); - stopDaemonMock.mockResolvedValue(undefined); - stopFuseDaemonServerMock.mockResolvedValue(undefined); - }); - - it('aborts active hydrations before clearing hydration state', async () => { - await stopVirtualDrive(); - - expect(abortAllHydrationsMock).toHaveBeenCalledOnce(); - expect(clearHydrationStateMock).toHaveBeenCalledOnce(); - expect(abortAllHydrationsMock.mock.invocationCallOrder[0]).toBeLessThan( - clearHydrationStateMock.mock.invocationCallOrder[0], - ); - }); - - it('shares an in-flight stop when stop is requested twice', async () => { - let resolveStopDaemon: () => void; - stopDaemonMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveStopDaemon = resolve; - }), - ); - - const firstStop = stopVirtualDrive(); - const secondStop = stopVirtualDrive(); - - expect(stopDaemonMock).toHaveBeenCalledOnce(); - - resolveStopDaemon!(); - await Promise.all([firstStop, secondStop]); - - expect(stopFuseDaemonServerMock).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/backend/features/virtual-drive/services/virtual-drive.service.ts b/src/backend/features/virtual-drive/services/virtual-drive.service.ts deleted file mode 100644 index d888226253..0000000000 --- a/src/backend/features/virtual-drive/services/virtual-drive.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Container } from 'diod'; -import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { DriveDependencyContainerFactory } from '../../../../apps/drive/dependency-injection/DriveDependencyContainerFactory'; -import { getRootVirtualDrive } from '../../../../apps/main/virtual-root-folder/service'; -import { startDaemon, stopDaemon } from './daemon.service'; -import { startFuseDaemonServer, stopFuseDaemonServer } from './server.service'; -import { updateVirtualDriveContainer } from './update-virtual-drive-container.service'; -import { DependencyInjectionUserProvider } from '../../../../apps/shared/dependency-injection/DependencyInjectionUserProvider'; -import { abortAllHydrations, clearHydrationState } from '../../fuse/on-read/download-cache/hydration-state'; -import { StorageFilesRepository } from '../../../../context/storage/StorageFiles/domain/StorageFilesRepository'; - -let container: Container | undefined; -let stopInFlight: Promise | undefined; - -export function getVirtualDriveContainer(): Container | undefined { - return container; -} - -export async function startVirtualDrive() { - const localRoot = getRootVirtualDrive(); - container = await DriveDependencyContainerFactory.build(); - await updateVirtualDriveContainer({ container, user: DependencyInjectionUserProvider.get() }); - /** - * Clear stale block-cache state and orphaned hydrated files before mounting. - * Future virtual-drive reads recreate cache files and hydrate only requested blocks. - */ - clearHydrationState(); - await container.get(StorageFilesRepository).deleteAll(); - await startFuseDaemonServer(container); - await startDaemon(localRoot); -} - -export async function stopVirtualDrive() { - if (stopInFlight) { - return stopInFlight; - } - - stopInFlight = stopVirtualDriveOnce(); - - try { - await stopInFlight; - } finally { - stopInFlight = undefined; - } -} - -async function stopVirtualDriveOnce() { - logger.debug({ msg: '[VIRTUAL DRIVE] stopping daemon...' }); - abortAllHydrations(); - await stopDaemon(); - logger.debug({ msg: '[VIRTUAL DRIVE] clearing storage cache...' }); - clearHydrationState(); - if (container) { - await container.get(StorageFilesRepository).deleteAll(); - } - logger.debug({ msg: '[VIRTUAL DRIVE] stopping server...' }); - await stopFuseDaemonServer(); - container = undefined; - logger.debug({ msg: '[VIRTUAL DRIVE] stopped' }); -} diff --git a/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts b/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts index 2692130374..6ba71d7be2 100644 --- a/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts +++ b/src/context/virtual-drive/folders/__mocks__/FolderRemoteFileSystemMock.ts @@ -48,6 +48,15 @@ export class FolderRemoteFileSystemMock implements RemoteFileSystem { this.searchWithMock.mockResolvedValueOnce(folder); } + shouldFailPersistWith(plainName: string, parentFolderUuid: string, error: RemoteFileSystemErrors) { + this.persistMock(plainName, parentFolderUuid); + this.persistMock.mockResolvedValueOnce(left(error)); + } + + shouldFindFolder(folder?: Folder) { + this.searchWithMock.mockResolvedValueOnce(folder); + } + shouldTrash(folder: Folder, error?: Error) { this.trashMock(folder.id); diff --git a/src/core/bootstrap/register-session-event-handlers.ts b/src/core/bootstrap/register-session-event-handlers.ts index d6a1e58506..210fc0c874 100644 --- a/src/core/bootstrap/register-session-event-handlers.ts +++ b/src/core/bootstrap/register-session-event-handlers.ts @@ -12,7 +12,7 @@ import { trySetupAntivirusIpcAndInitialize } from '../../apps/main/background-pr import { getUserAvailableProductsAndStore } from '../../backend/features/payments/services/get-user-available-products-and-store'; import { registerBackupHandlers } from '../../backend/features/backup/register-backup-handlers'; import { startBackupsIfAvailable } from '../../backend/features/backup/start-backups-if-available'; -import { stopVirtualDrive } from '../../backend/features/virtual-drive/services/virtual-drive.service'; +import { stopVirtualDrive } from '../../backend/features/virtual-drive/services/drive-folder/virtual-drive.service'; function onWidgetIsReady() { registerBackupHandlers(); diff --git a/src/core/bootstrap/setup-environment-debug-tools.ts b/src/core/bootstrap/setup-environment-debug-tools.ts index 03f53b8aae..fab4b86473 100644 --- a/src/core/bootstrap/setup-environment-debug-tools.ts +++ b/src/core/bootstrap/setup-environment-debug-tools.ts @@ -7,6 +7,8 @@ export function setupEnvironmentDebugTools() { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line @typescript-eslint/no-var-requires - require('electron-debug')({ showDevTools: false }); + const electronDebug = require('electron-debug'); + const debug = electronDebug.default ?? electronDebug; + debug({ showDevTools: false }); } } diff --git a/src/core/electron/store/app-store.interface.ts b/src/core/electron/store/app-store.interface.ts index 11cbaac99f..e1f4c3d11e 100644 --- a/src/core/electron/store/app-store.interface.ts +++ b/src/core/electron/store/app-store.interface.ts @@ -11,6 +11,7 @@ export type SavedConfig = { backgroundScanEnabled: boolean; backupInterval: number; lastBackup: number; + virtualDriveRoot: string; syncRoot: string; lastSavedListing: string; lastSync: number; @@ -35,6 +36,7 @@ export type AppStore = { backgroundScanEnabled: boolean; backupInterval: number; lastBackup: number; + virtualDriveRoot: string; syncRoot: string; lastSavedListing: string; lastSync: number; diff --git a/src/core/electron/store/defaults.ts b/src/core/electron/store/defaults.ts index 38570a8f5f..58caafb3ff 100644 --- a/src/core/electron/store/defaults.ts +++ b/src/core/electron/store/defaults.ts @@ -15,6 +15,7 @@ export const defaults: AppStore = { backgroundScanEnabled: true, backupInterval: 86_400_000, // 24h lastBackup: -1, + virtualDriveRoot: '', syncRoot: '', lastSavedListing: '', lastSync: -1, @@ -46,6 +47,7 @@ export const fieldsToSave: Array = [ 'backgroundScanEnabled', 'backupInterval', 'lastBackup', + 'virtualDriveRoot', 'syncRoot', 'lastSavedListing', 'lastSync', diff --git a/src/core/quit/quit.handler.test.ts b/src/core/quit/quit.handler.test.ts index 14aaffcd7e..2b6cbb9be9 100644 --- a/src/core/quit/quit.handler.test.ts +++ b/src/core/quit/quit.handler.test.ts @@ -1,11 +1,11 @@ import { app, ipcMain } from 'electron'; import { call } from 'tests/vitest/utils.helper'; -import * as virtualDriveServiceModule from '../../backend/features/virtual-drive/services/virtual-drive.service'; +import * as virtualDriveServiceModule from '../../backend/features/virtual-drive/services/drive-folder/virtual-drive.service'; import { partialSpyOn } from 'tests/vitest/utils.helper'; import * as registerQuitHandlerModule from './quit.handler'; describe('quit', () => { - const stopVirtualDriveMock = partialSpyOn(virtualDriveServiceModule, 'stopVirtualDrive'); + const stopVirtualDriveMock = partialSpyOn(virtualDriveServiceModule, 'stopVirtualDriveOnce'); const appQuitMock = partialSpyOn(app, 'quit'); const appOnMock = partialSpyOn(app, 'on', false); const ipcMainOnMock = partialSpyOn(ipcMain, 'on', false); @@ -28,7 +28,7 @@ describe('quit', () => { expect(appOnMock).toBeCalledWith('before-quit', expect.any(Function)); }); - it('should call stopAndClearFuseApp on user-quit event', async () => { + it('should call stopVirtualDriveOnce on user-quit event', async () => { registerQuitHandlerModule.registerQuitHandler(); await (ipcMainOnMock.mock.calls[0][1] as () => Promise)(); diff --git a/src/core/quit/quit.handler.ts b/src/core/quit/quit.handler.ts index c3318b732e..9a13fc6fc2 100644 --- a/src/core/quit/quit.handler.ts +++ b/src/core/quit/quit.handler.ts @@ -1,6 +1,6 @@ import { app, ipcMain } from 'electron'; import { logger } from '@internxt/drive-desktop-core/build/backend'; -import { stopVirtualDrive } from '../../backend/features/virtual-drive/services/virtual-drive.service'; +import { stopVirtualDriveOnce } from '../../backend/features/virtual-drive/services/drive-folder/virtual-drive.service'; export function registerQuitHandler() { let isQuitting = false; @@ -12,7 +12,7 @@ export function registerQuitHandler() { isQuitting = true; logger.debug({ msg: '[APP] quitting, stopping virtual drive...' }); - await stopVirtualDrive(); + await stopVirtualDriveOnce(); logger.debug({ msg: '[APP] virtual drive stopped, quitting' }); app.quit(); };