+
+
+
diff --git a/src/apps/renderer/context/CleanerContext.tsx b/src/apps/renderer/context/CleanerContext.tsx
index 4bb7475d86..bd01ea78fb 100644
--- a/src/apps/renderer/context/CleanerContext.tsx
+++ b/src/apps/renderer/context/CleanerContext.tsx
@@ -70,7 +70,10 @@ export function CleanerProvider({ children }: { children: ReactNode }) {
try {
window.electron.cleaner.startCleanup(viewModel);
} catch (error) {
- console.error('Failed to start cleanup:', error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to start cleanup',
+ error,
+ });
}
};
@@ -78,7 +81,10 @@ export function CleanerProvider({ children }: { children: ReactNode }) {
try {
window.electron.cleaner.stopCleanup();
} catch (error) {
- console.error('Failed to stop cleanup:', error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to stop cleanup',
+ error,
+ });
}
};
diff --git a/src/apps/renderer/context/DeviceContext.tsx b/src/apps/renderer/context/DeviceContext.tsx
index a354355a4f..dc7a1e3aa9 100644
--- a/src/apps/renderer/context/DeviceContext.tsx
+++ b/src/apps/renderer/context/DeviceContext.tsx
@@ -1,5 +1,5 @@
import { createContext, Dispatch, ReactNode, SetStateAction, useEffect, useState } from 'react';
-import { Device } from '../../main/device/service';
+import { Device } from '../../../backend/features/backup/types/Device';
import { useDevices } from '../hooks/devices/useDevices';
export type DeviceState = { status: 'LOADING' | 'ERROR' } | { status: 'SUCCESS'; device: Device };
@@ -25,14 +25,15 @@ export function DeviceProvider({ children }: { children: ReactNode }) {
const [selected, setSelected] = useState();
const { devices, getDevices } = useDevices();
- useEffect(() => {
- refreshDevice();
-
- const removeDeviceCreatedListener = window.electron.onDeviceCreated(setCurrentDevice);
- return () => {
- removeDeviceCreatedListener();
- };
- }, []);
+ const setCurrentDevice = (newDevice: Device) => {
+ try {
+ setDeviceState({ status: 'SUCCESS', device: newDevice });
+ setCurrent(newDevice);
+ setSelected(newDevice);
+ } catch {
+ setDeviceState({ status: 'ERROR' });
+ }
+ };
const refreshDevice = () => {
setDeviceState({ status: 'LOADING' });
@@ -45,15 +46,14 @@ export function DeviceProvider({ children }: { children: ReactNode }) {
});
};
- const setCurrentDevice = (newDevice: Device) => {
- try {
- setDeviceState({ status: 'SUCCESS', device: newDevice });
- setCurrent(newDevice);
- setSelected(newDevice);
- } catch {
- setDeviceState({ status: 'ERROR' });
- }
- };
+ useEffect(() => {
+ refreshDevice();
+
+ const removeDeviceCreatedListener = window.electron.onDeviceCreated(setCurrentDevice);
+ return () => {
+ removeDeviceCreatedListener();
+ };
+ }, []);
const deviceRename = async (deviceName: string) => {
setDeviceState({ status: 'LOADING' });
@@ -64,7 +64,10 @@ export function DeviceProvider({ children }: { children: ReactNode }) {
setCurrent(updatedDevice);
setSelected(updatedDevice);
} catch (err) {
- console.log(err);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to rename device',
+ error: err,
+ });
setDeviceState({ status: 'ERROR' });
}
};
diff --git a/src/apps/renderer/hooks/ClientPlatform.tsx b/src/apps/renderer/hooks/ClientPlatform.tsx
deleted file mode 100644
index 1f7e7336c2..0000000000
--- a/src/apps/renderer/hooks/ClientPlatform.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import { DesktopPlatform } from '../../main/platform/DesktopPlatform';
-
-export default function useClientPlatform(): DesktopPlatform | undefined {
- const [clientPlatform, setPlatform] = useState();
-
- useEffect(() => {
- window.electron.getPlatform().then(setPlatform);
- }, []);
-
- return clientPlatform;
-}
diff --git a/src/apps/renderer/hooks/antivirus/useAntivirus.tsx b/src/apps/renderer/hooks/antivirus/useAntivirus.tsx
index a60ff093ee..352ec1d30e 100644
--- a/src/apps/renderer/hooks/antivirus/useAntivirus.tsx
+++ b/src/apps/renderer/hooks/antivirus/useAntivirus.tsx
@@ -38,6 +38,41 @@ export const useAntivirus = (): AntivirusContext => {
const [showErrorState, setShowErrorState] = useState(false);
const [view, setView] = useState('loading');
+ const handleProgress = (progress: {
+ scanId?: string;
+ currentScanPath?: string;
+ infectedFiles?: string[];
+ progress?: number;
+ totalScannedFiles?: number;
+ done?: boolean;
+ }) => {
+ if (!progress) return;
+
+ if (progress.currentScanPath) {
+ setCurrentScanPath(progress.currentScanPath);
+ }
+
+ if (typeof progress.totalScannedFiles === 'number') {
+ setCountScannedFiles(progress.totalScannedFiles);
+ }
+
+ if (typeof progress.progress === 'number') {
+ setProgressRatio(progress.progress);
+ }
+
+ if (Array.isArray(progress.infectedFiles) && progress.infectedFiles.length > 0) {
+ setInfectedFiles(progress.infectedFiles);
+ }
+
+ if (progress.done) {
+ setProgressRatio(100);
+ setTimeout(() => {
+ setIsScanning(false);
+ setIsScanCompleted(true);
+ }, 500);
+ }
+ };
+
useEffect(() => {
window.electron.antivirus.onScanProgress(handleProgress);
return () => {
@@ -103,41 +138,6 @@ export const useAntivirus = (): AntivirusContext => {
}
};
- const handleProgress = (progress: {
- scanId?: string;
- currentScanPath?: string;
- infectedFiles?: string[];
- progress?: number;
- totalScannedFiles?: number;
- done?: boolean;
- }) => {
- if (!progress) return;
-
- if (progress.currentScanPath) {
- setCurrentScanPath(progress.currentScanPath);
- }
-
- if (typeof progress.totalScannedFiles === 'number') {
- setCountScannedFiles(progress.totalScannedFiles);
- }
-
- if (typeof progress.progress === 'number') {
- setProgressRatio(progress.progress);
- }
-
- if (Array.isArray(progress.infectedFiles) && progress.infectedFiles.length > 0) {
- setInfectedFiles(progress.infectedFiles);
- }
-
- if (progress.done) {
- setProgressRatio(100);
- setTimeout(() => {
- setIsScanning(false);
- setIsScanCompleted(true);
- }, 500);
- }
- };
-
const resetStates = () => {
setCurrentScanPath('');
setCountScannedFiles(0);
@@ -192,9 +192,9 @@ export const useAntivirus = (): AntivirusContext => {
const isDirectory = scanType === 'folders' || !seemsLikeFile;
return {
- path: path,
+ path,
itemName: cleanPath.split('/').pop() || cleanPath,
- isDirectory: isDirectory,
+ isDirectory,
};
}
return item;
diff --git a/src/apps/renderer/hooks/backups/useBackupFatalIssue.tsx b/src/apps/renderer/hooks/backups/useBackupFatalIssue.tsx
index c0b27f288b..f0630ce6b7 100644
--- a/src/apps/renderer/hooks/backups/useBackupFatalIssue.tsx
+++ b/src/apps/renderer/hooks/backups/useBackupFatalIssue.tsx
@@ -4,6 +4,38 @@ import { BackupInfo } from '../../../backups/BackupInfo';
import { useTranslationContext } from '../../context/LocalContext';
import { shortMessages } from '../../messages/virtual-drive-error';
+type Action = {
+ name: string;
+ fn: undefined | ((backup: BackupInfo) => Promise);
+};
+
+type BackupErrorActionMap = Record;
+
+export const backupsErrorActions: BackupErrorActionMap = {
+ BASE_DIRECTORY_DOES_NOT_EXIST: {
+ name: 'issues.actions.find-folder',
+ fn: findBackupFolder,
+ },
+ NOT_EXISTS: undefined,
+ NO_INTERNET: undefined,
+ NO_REMOTE_CONNECTION: undefined,
+ BAD_RESPONSE: undefined,
+ EMPTY_FILE: undefined,
+ FILE_TOO_BIG: undefined,
+ FILE_NON_EXTENSION: undefined,
+ UNKNOWN: undefined,
+ DUPLICATED_NODE: undefined,
+ ACTION_NOT_PERMITTED: undefined,
+ FILE_ALREADY_EXISTS: undefined,
+ COULD_NOT_ENCRYPT_NAME: undefined,
+ BAD_REQUEST: undefined,
+ INSUFFICIENT_PERMISSION: undefined,
+ NOT_ENOUGH_SPACE: undefined,
+ ABORTED: undefined,
+ RATE_LIMITED: undefined,
+ INTERNAL_SERVER_ERROR: undefined,
+};
+
type FixAction = {
name: string;
fn: () => Promise;
@@ -48,35 +80,9 @@ export function useBackupFatalIssue(backup: BackupInfo) {
}
async function findBackupFolder(backup: BackupInfo) {
- const result = await window.electron.changeBackupPath(backup.pathname);
- if (result) window.electron.startBackupsProcess();
-}
+ const chosen = await window.electron.getFolderPath();
+ if (!chosen) return;
-type Action = {
- name: string;
- fn: undefined | ((backup: BackupInfo) => Promise);
-};
-
-type BackupErrorActionMap = Record;
-
-export const backupsErrorActions: BackupErrorActionMap = {
- BASE_DIRECTORY_DOES_NOT_EXIST: {
- name: 'issues.actions.find-folder',
- fn: findBackupFolder,
- },
- NOT_EXISTS: undefined,
- NO_INTERNET: undefined,
- NO_REMOTE_CONNECTION: undefined,
- BAD_RESPONSE: undefined,
- EMPTY_FILE: undefined,
- FILE_TOO_BIG: undefined,
- FILE_NON_EXTENSION: undefined,
- UNKNOWN: undefined,
- DUPLICATED_NODE: undefined,
- ACTION_NOT_PERMITTED: undefined,
- FILE_ALREADY_EXISTS: undefined,
- COULD_NOT_ENCRYPT_NAME: undefined,
- BAD_REQUEST: undefined,
- INSUFFICIENT_PERMISSION: undefined,
- NOT_ENOUGH_SPACE: undefined,
-};
+ const { data } = await window.electron.changeBackupPath({ currentPath: backup.pathname, newPath: chosen.path });
+ if (data) window.electron.startBackupsProcess();
+}
diff --git a/src/apps/renderer/hooks/backups/useBackups.tsx b/src/apps/renderer/hooks/backups/useBackups.tsx
index 400cdf3cd8..9a169ec57c 100644
--- a/src/apps/renderer/hooks/backups/useBackups.tsx
+++ b/src/apps/renderer/hooks/backups/useBackups.tsx
@@ -1,7 +1,8 @@
import { useContext, useEffect, useState } from 'react';
import { BackupInfo } from '../../../backups/BackupInfo';
import { DeviceContext } from '../../context/DeviceContext';
-import { Device } from '../../../main/device/service';
+import { Device } from '../../../../backend/features/backup/types/Device';
+import { AbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
export type BackupsState = 'LOADING' | 'ERROR' | 'SUCCESS';
@@ -11,7 +12,7 @@ export interface BackupContextProps {
disableBackup: (backup: BackupInfo) => Promise;
addBackup: () => Promise;
deleteBackups: (device: Device, isCurrent?: boolean) => Promise;
- downloadBackups: (device: Device) => Promise;
+ downloadBackups: (device: Device, pathName: AbsolutePath) => Promise;
abortDownloadBackups: (device: Device) => void;
hasExistingBackups: boolean;
}
@@ -56,8 +57,8 @@ export function useBackups(): BackupContextProps {
}, [selected, devices]);
async function addBackup() {
- const newBackup = await window.electron.addBackup();
- if (!newBackup) return;
+ const { data: newBackup, error } = await window.electron.addBackup();
+ if (error) return;
setBackups((prevBackups) => {
const existingIndex = prevBackups.findIndex((backup) => backup.folderId === newBackup.folderId);
@@ -91,15 +92,13 @@ export function useBackups(): BackupContextProps {
}
}
- async function downloadBackups(device: Device) {
- try {
- await window.electron.downloadBackup(device);
- } catch (error) {
- reportError(error);
- }
+ async function downloadBackups(device: Device, pathName: AbsolutePath) {
+ if (!selected) return;
+ await window.electron.downloadBackup(device, pathName);
}
function abortDownloadBackups(device: Device) {
+ if (!selected) return;
return window.electron.abortDownloadBackups(device.uuid);
}
diff --git a/src/apps/renderer/hooks/devices/useDevices.tsx b/src/apps/renderer/hooks/devices/useDevices.tsx
index 958a0741f9..5b3de5d94b 100644
--- a/src/apps/renderer/hooks/devices/useDevices.tsx
+++ b/src/apps/renderer/hooks/devices/useDevices.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { Device } from '../../../main/device/service';
+import { Device } from '../../../../backend/features/backup/types/Device';
export function useDevices() {
const [devices, setDevices] = useState>([]);
diff --git a/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.test.ts b/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.test.ts
index 7b5970aa8a..1274ea6e54 100644
--- a/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.test.ts
+++ b/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.test.ts
@@ -10,6 +10,7 @@ describe('useUserAvailableProducts', () => {
antivirus: false,
cleaner: true,
};
+ const loggerErrorMock = vi.mocked(window.electron.logger.error);
beforeEach(() => {
vi.clearAllMocks();
@@ -82,17 +83,16 @@ describe('useUserAvailableProducts', () => {
const error = new Error('Failed to fetch products');
vi.mocked(window.electron.userAvailableProducts.get).mockRejectedValue(error);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
const { result } = renderHook(() => useUserAvailableProducts());
// Wait for the promise to reject and be handled
await vi.waitFor(() => {
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch user available products:', error);
+ expect(loggerErrorMock).toHaveBeenCalledWith({
+ msg: '[RENDERER] Failed to fetch user available products',
+ error,
+ });
});
expect(result.current.products).toBeUndefined();
-
- consoleErrorSpy.mockRestore();
});
});
diff --git a/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.ts b/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.ts
index 57bd789962..58d659a167 100644
--- a/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.ts
+++ b/src/apps/renderer/hooks/useUserAvailableProducts/useUserAvailableProducts.ts
@@ -10,7 +10,10 @@ export function useUserAvailableProducts() {
.get()
.then(setProducts)
.catch((error) => {
- console.error('Failed to fetch user available products:', error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to fetch user available products',
+ error,
+ });
});
userAvailableProducts.subscribe();
diff --git a/src/apps/renderer/hooks/useVirtualDriveStatus.tsx b/src/apps/renderer/hooks/useVirtualDriveStatus.tsx
index caa4e37efb..47f266bc8e 100644
--- a/src/apps/renderer/hooks/useVirtualDriveStatus.tsx
+++ b/src/apps/renderer/hooks/useVirtualDriveStatus.tsx
@@ -9,13 +9,19 @@ export default function useVirtualDriveStatus() {
.getVirtualDriveStatus()
.then((status: FuseDriveStatus) => setVirtualDriveStatus(status))
.catch((err) => {
- reportError(err);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to fetch virtual drive status',
+ error: err,
+ });
});
}, []);
useEffect(() => {
const removeListener = window.electron.onVirtualDriveStatusChange((status) => {
- console.debug('status changed');
+ window.electron.logger.debug({
+ msg: '[RENDERER] Virtual drive status changed',
+ status,
+ });
setVirtualDriveStatus(status.status);
});
diff --git a/src/apps/renderer/localize/locales/en.json b/src/apps/renderer/localize/locales/en.json
index f98187a7a9..d1484e0966 100644
--- a/src/apps/renderer/localize/locales/en.json
+++ b/src/apps/renderer/localize/locales/en.json
@@ -1,35 +1,11 @@
{
"login": {
- "email": {
- "section": "Email address"
- },
- "password": {
- "section": "Password",
- "placeholder": "Password",
- "forgotten": "Forgot your password?",
- "hide": "Hide",
- "show": "Show"
- },
"action": {
- "login": "Log in",
- "is-logging-in": "Logging you in...",
"login-in-browser": "Log in with browser"
},
"create-account": "Create account",
"welcome": "Welcome to Internxt",
- "no-account": "Don't have an account?",
- "2fa": {
- "section": "Authentication code",
- "description": "You have configured two factor authentication, please enter the 6 digit code",
- "change-account": "Change account",
- "wrong-code": "Incorrect code, try again"
- },
- "error": {
- "empty-fields": "Incorrect password or email"
- },
- "warning": {
- "no-internet": "No internet connection"
- }
+ "no-account": "Don't have an account?"
},
"onboarding": {
"slides": {
@@ -68,48 +44,7 @@
"skip": "Skip",
"open-drive": "Open Internxt Drive",
"new": "New",
- "platform-phrase": {
- "windows": "file explorer",
- "linux": "file browser",
- "macos": "Finder"
- }
- }
- },
- "migration": {
- "slides": {
- "welcome": {
- "title": "Internxt’s new desktop app is ready to go!",
- "features": {
- "title": "Fresh updates:",
- "feature-1": "Select what you want to download and save hard drive space.",
- "feature-2": "Native OS look and feel for managing your files and folders."
- }
- },
- "migration": {
- "title": "Let's make sure all your files are safe",
- "in-progress": "Uploading pending files",
- "item-progress": "{{processed_items}} of {{total_items}} items uploaded"
- },
- "migration-failed": {
- "title": "Let's make sure all your files are safe",
- "message": "Some files could not be uploaded",
- "description": "We’ve moved these files to your desktop, drag and drop them to your Internxt Drive",
- "show-files": "Show files"
- },
- "delete-old-drive-folder": {
- "title": "Same Internxt Drive, new location",
- "message": "Your personal Internxt Drive folder is located in your {{platform_app}} sidebar"
- },
- "new-widget": {
- "title": "Be more productive with our redesigned widget",
- "message": "We've reimagined and rebuilt our widget to reduce clutter, add convenience, and boost speed.",
- "message-2": "All changes now update in real time."
- }
- },
- "common": {
- "continue": "Continue",
- "cancel": "Cancel",
- "open-drive": "Open Internxt Drive"
+ "platform-phrase": "file explorer"
}
},
"widget": {
@@ -121,11 +56,10 @@
"dropdown": {
"preferences": "Preferences",
"issues": "Issues",
- "send-feedback": "Send feedback",
"support": "Support",
+ "referAndEarn": "Refer and Earn",
"logout": "Log out",
"quit": "Quit",
- "antivirus": "Antivirus",
"cleaner": "Cleaner",
"new": "New",
"sync": "Sync"
@@ -146,22 +80,12 @@
"renamed": "Renamed"
}
},
- "no-activity": {
- "title": "There is no recent activity",
- "description": "Information will show up here when changes are made to sync your local folder with Internxt Drive"
- },
"upToDate": {
"title": "Your files are up to date",
"subtitle": "Sync activity will show up here"
},
"errors": {
- "sync": {},
- "backups": {
- "folder-not-found": {
- "text": "Can't upload backup, missing folder",
- "action": "View error"
- }
- }
+ "sync": {}
}
},
"footer": {
@@ -171,7 +95,6 @@
"failed": "Sync failed"
},
"errors": {
- "lock": "Sync locked by other device",
"offline": "Not connected to the internet"
}
},
@@ -182,9 +105,7 @@
},
"virtual-drive-error": {
"title": "Can't mount your drive",
- "message": "We are having issues mounting your Internxt Drive, try unmounting it manually and starting the app again",
- "mounting": "Mounting...",
- "button": "Mount"
+ "message": "We are having issues mounting your Internxt Drive, try unmounting it manually and starting the app again"
},
"banners": {
"update-available": {
@@ -234,13 +155,8 @@
"dark": "Dark"
}
},
- "sync": {
- "folder": "Internxt Drive Folder",
- "change-folder": "Change folder"
- },
"app-info": {
"open-logs": "Open logs",
- "open-migration": "Start migration",
"more": "Learn more about Internxt"
}
},
@@ -250,17 +166,11 @@
"display": "Used {{used}} of {{total}}",
"upgrade": "Upgrade",
"change": "Change",
- "plan": "Current plan",
"free": "Free",
"loadError": {
"title": "Couldn't fetch your usage details",
"action": "Retry"
},
- "current": {
- "used": "Used",
- "of": "of",
- "in-use": "in use"
- },
"full": {
"title": "Your storage is full",
"subtitle": "You can't upload, sync, or backup files. Upgrade now your plan or remove files to save up space."
@@ -278,20 +188,17 @@
"selected-folder_one": "{{count}} folder",
"selected-folder_other": "{{count}} folders",
"add-folders": "Click + to select the folders\n you want to back up",
- "activate": "Back up your folders and files",
"view-backups": "Browse files",
"selected-folders-title": "Selected folders",
"select-folders": "Change folders",
"last-backup-had-issues": "Last backup had some issues",
"see-issues": "See issues",
- "backing-up": "Backing up...",
"backups-help": "Backups Help",
"this-device": "This device",
"devices": "Devices",
"action": {
"start": "Backup now",
"stop": "Stop backup",
- "running": "Backup in progress {{progress}}",
"last-run": "Last updated"
},
"frequency": {
@@ -334,12 +241,6 @@
"title": "Something went wrong while scanning the directory",
"button": "Try again"
},
- "deactivateAntivirus": {
- "title": "Windows Defender is active",
- "description": "Please disable Windows Defender to be able to use Internxt Antivirus. To do this, open Windows Security > Virus and Threat Protection > Manage settings > disable Real-time protection.",
- "retry": "Retry",
- "cancel": "Cancel"
- },
"realtimeProtection": {
"title": "Real-time protection",
"infoAriaLabel": "About real-time protection",
@@ -380,8 +281,7 @@
},
"securityWarning": {
"title": "Security warning",
- "description": "Malware is still present, and your device is at risk.",
- "confirmToCancel": "Are you sure you want to cancel?"
+ "description": "Malware is still present, and your device is at risk."
}
}
},
@@ -389,7 +289,6 @@
"scanning": "Scanning...",
"scannedFiles": "Scanned files",
"detectedFiles": "Detected files",
- "errorWhileScanning": "An error occurred while scanning the items. Please try again.",
"noFilesFound": {
"title": "No threats were found",
"subtitle": "No further actions are necessary"
@@ -404,11 +303,7 @@
"filesContainingMalwareModal": {
"title": "Files containing malware",
"selectedItems": "Selected {{selectedFiles}} out of {{totalFiles}}",
- "selectAll": "Select all",
- "actions": {
- "cancel": "Cancel",
- "remove": "Remove"
- }
+ "selectAll": "Select all"
}
},
"cleaner": {
@@ -465,9 +360,7 @@
},
"no-issues": "No issues found",
"actions": {
- "select-folder": "Select folder",
- "find-folder": "Locate folder",
- "try-again": "Try again"
+ "find-folder": "Locate folder"
},
"short-error-messages": {
"unknown": "Unknown error",
@@ -492,41 +385,9 @@
"insufficient-permission-accessing-base-directory": "Internxt App does not have permission to access your sync folder",
"cannot-access-base-directory": "We could not access your local folder",
"cannot-access-tmp-directory": "We could not access your local folder",
- "unknown": "An unknown error ocurred while trying to sync your files",
- "empty-file": "We don't support files with a size of 0 bytes because of our processes of sharding and encryption",
- "bad-response": "We got a bad response from our servers while processing this file. Please, try starting the sync process again.",
- "file-does-not-exist": "This file was present when we compared your local folder with your Internxt drive but disappeared when we tried to access it. If you deleted this file, don't worry, this error should dissapear the next time the sync process starts.",
- "file-too-big": "Max upload size is 20GB. Please try smaller files.",
- "file-non-extension": "Files without extensions are not supported. Not synchronized.",
- "duplicated-node": "There are two elements (file or folder) with the same name on a folder. Rename one of them to sync them both",
- "action-not-permitted": "The operation could not be completed, possibly due to a conflict with another file.",
- "file-already-exists": "Unable to complete the operation. The file already exists on Internxt servers",
- "not-enough-space": "You have not enough space to complete the operation"
- },
- "report-modal": {
- "actions": {
- "close": "Close",
- "cancel": "Cancel",
- "report": "Report",
- "send": "Send"
- },
- "help-url": "To get help visit",
- "report": "You can also send a report about this error.",
- "user-comments": "Comments",
- "include-logs": "Include the logs of this sync process for debug purposes"
+ "unknown": "An unknown error ocurred while trying to sync your files"
}
},
- "feedback": {
- "window-title": "Internxt Desktop feedback",
- "title": "Share feedback with Internxt",
- "description": "Your feedback makes Internxt improve and helps us to create better product experiences",
- "placeholder": "Let us know what's in your mind, what you'd like to improve or describe the bug or issue",
- "characters-count": "{{character_count}}/{{character_limit}}",
- "send-feedback": "Send feedback",
- "sent-title": "Thank you for sharing your feedback",
- "sent-message": "We really appreciate your time and effort to help us improve our services.",
- "close": "Close"
- },
"common": {
"cancel": "Cancel"
},
diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json
index 78daef474e..1903dcd242 100644
--- a/src/apps/renderer/localize/locales/es.json
+++ b/src/apps/renderer/localize/locales/es.json
@@ -1,35 +1,11 @@
{
"login": {
- "email": {
- "section": "Correo electrónico"
- },
- "password": {
- "section": "Contraseña",
- "placeholder": "Contraseña",
- "forgotten": "¿Has olvidado tu contraseña?",
- "hide": "Ocultar",
- "show": "Mostrar"
- },
"action": {
- "login": "Iniciar sesión",
- "is-logging-in": "Iniciando sesión...",
"login-in-browser": "Iniciar sesión con el navegador"
},
"create-account": "Crear cuenta",
"welcome": "Bienvenido a Internxt",
- "no-account": "¿No tienes cuenta?",
- "2fa": {
- "section": "Código de autenticación",
- "description": "Has configurado la autenticación en dos pasos, por favor introduce el código de 6 dígitos",
- "change-account": "Cambiar cuenta",
- "wrong-code": "Código incorrecto, inténtalo de nuevo"
- },
- "error": {
- "empty-fields": "Contraseña o correo electrónico incorrectos"
- },
- "warning": {
- "no-internet": "Sin conexión a internet"
- }
+ "no-account": "¿No tienes cuenta?"
},
"onboarding": {
"slides": {
@@ -68,48 +44,7 @@
"open-drive": "Abrir Internxt Drive",
"skip": "Saltar",
"new": "Nuevo",
- "platform-phrase": {
- "windows": "explorador de archivos",
- "linux": "buscador de archivos",
- "macos": "Finder"
- }
- }
- },
- "migration": {
- "slides": {
- "welcome": {
- "title": "Nueva actualización de la aplicación de escritorio de Internxt!",
- "features": {
- "title": "Novedades en esta versión:",
- "feature-1": "Selecciona lo que desees descargar y ahorra espacio en el disco duro.",
- "feature-2": "Apariencia y sensación nativa del sistema operativo para gestionar tus archivos y carpetas."
- }
- },
- "migration": {
- "title": "Nos aseguramos de que todos tus archivos están a salvo",
- "in-progress": "Subiendo archivos pendientes",
- "item-progress": "{{processed_items}} de {{total_items}} archivos subidos"
- },
- "migration-failed": {
- "title": "Nos aseguramos de que todos tus archivos están a salvo",
- "message": "No se han podido cargar algunos archivos",
- "description": "Hemos movido esos archivos a tu escritorio, arrástralos y suéltalos en tu Internxt Drive",
- "show-files": "Mostrar archivos"
- },
- "delete-old-drive-folder": {
- "title": "Tu Internxt Drive de siempre, en una nueva ubicación",
- "message": "Tu carpeta personal de Internxt Drive se encuentra en la barra lateral {{platform_app}}."
- },
- "new-widget": {
- "title": "Sé más productivo con nuestro widget rediseñado",
- "message": "Hemos rediseñado y reconstruido nuestro widget para aumentar la productividad, la comodidad y la velocidad.",
- "message-2": "Todos los cambios se actualizan en tiempo real."
- }
- },
- "common": {
- "continue": "Continuar",
- "cancel": "Cancelar",
- "open-drive": "Abrir Internxt Drive"
+ "platform-phrase": "explorador de archivos"
}
},
"widget": {
@@ -121,11 +56,10 @@
"dropdown": {
"preferences": "Preferencias",
"issues": "Lista de errores",
- "send-feedback": "Enviar feedback",
"support": "Ayuda",
+ "referAndEarn": "Recomienda y gana",
"logout": "Cerrar sesión",
"quit": "Salir",
- "antivirus": "Antivirus",
"cleaner": "Cleaner",
"new": "Nuevo",
"sync": "Sincronizar"
@@ -146,22 +80,12 @@
"renamed": "Renombrado"
}
},
- "no-activity": {
- "title": "No hay actividad reciente",
- "description": "La información aparecerá aquí cuando hagas cambios, para sincronizar tu carpeta local con Internxt Drive"
- },
"upToDate": {
"title": "Tus archivos están actualizados",
"subtitle": "La actividad de sincronización se mostrará aquí"
},
"errors": {
- "sync": {},
- "backups": {
- "folder-not-found": {
- "text": "No se pudo realizar la copia, no se encuentra la carpeta",
- "action": "Ver error"
- }
- }
+ "sync": {}
}
},
"footer": {
@@ -171,7 +95,6 @@
"failed": "Sincronización fallida"
},
"errors": {
- "lock": "Sincronización bloqueada por otro dispositivo",
"offline": "No hay conexión a internet"
}
},
@@ -182,9 +105,7 @@
},
"virtual-drive-error": {
"title": "No se puede montar tu Drive",
- "message": "Estamos teniendo problemas al montar tu unidad Internxt. Intenta desmontarla manualmente y luego reiniciar la aplicación.",
- "mounting": "Montando..",
- "button": "Montar"
+ "message": "Estamos teniendo problemas al montar tu unidad Internxt. Intenta desmontarla manualmente y luego reiniciar la aplicación."
},
"banners": {
"update-available": {
@@ -234,13 +155,8 @@
"dark": "Oscuro"
}
},
- "sync": {
- "folder": "Carpeta Internxt Drive",
- "change-folder": "Cambiar carpeta"
- },
"app-info": {
"open-logs": "Abrir registros",
- "open-migration": "Empezar migración",
"more": "Más información sobre Internxt"
}
},
@@ -250,17 +166,11 @@
"display": "Usado {{used}} de {{total}}",
"upgrade": "Comprar espacio",
"change": "Cambiar",
- "plan": "Plan actual",
"free": "Gratis",
"loadError": {
"title": "No se han podido obtener tus datos de uso",
"action": "Reintentar"
},
- "current": {
- "used": "usado",
- "of": "de",
- "in-use": "usado"
- },
"full": {
"title": "Tu almacenamiento está lleno",
"subtitle": "No puedes subir, sincronizar ni hacer copias de seguridad de archivos. Amplía ahora tu plan o elimina archivos para ahorrar espacio."
@@ -278,20 +188,17 @@
"add-folders": "Haz clic en + para hacer una copia de seguridad de tus carpetas",
"selected-folder_one": "{{count}} carpeta",
"selected-folder_other": "{{count}} carpetas",
- "activate": "Hacer copia de seguridad de tus carpetas",
"view-backups": "Explorar archivos",
"selected-folders-title": "Carpetas seleccionadas",
"select-folders": "Cambiar carpetas",
"last-backup-had-issues": "La última copia de seguridad tuvo algunos problemas",
"see-issues": "Ver problemas",
- "backing-up": "Haciendo la copia",
"backups-help": "Ayuda sobre copias de seguridad",
"this-device": "Este dispositivo",
"devices": "Dispositivos",
"action": {
"start": "Hacer copia",
"stop": "Stop backup",
- "running": "Subiendo backup {{progress}}",
"last-run": "Última ejecución"
},
"frequency": {
@@ -334,12 +241,6 @@
"title": "Algo salió mal al escanear el directorio",
"button": "Intentar de nuevo"
},
- "deactivateAntivirus": {
- "title": "Windows Defender está activo",
- "description": "Por favor, desactiva Windows Defender para poder usar Internxt Antivirus. Para hacerlo, abre Seguridad de Windows > Protección contra virus y amenazas > Administrar configuración > desactiva la Protección en tiempo real.",
- "retry": "Reintentar",
- "cancel": "Cancelar"
- },
"realtimeProtection": {
"title": "Protección en tiempo real",
"infoAriaLabel": "Acerca de la protección en tiempo real",
@@ -380,8 +281,7 @@
},
"securityWarning": {
"title": "Advertencia de seguridad",
- "description": "El malware sigue presente y tu dispositivo está en riesgo.",
- "confirmToCancel": "¿Estás seguro de querer cancelar?"
+ "description": "El malware sigue presente y tu dispositivo está en riesgo."
}
}
},
@@ -389,7 +289,6 @@
"scanning": "Escaneando...",
"scannedFiles": "Archivos escaneados",
"detectedFiles": "Archivos detectados",
- "errorWhileScanning": "Ocurrió un error al escanear los elementos. Por favor, intenta nuevamente.",
"noFilesFound": {
"title": "No se encontraron amenazas",
"subtitle": "No es necesario realizar más acciones"
@@ -404,11 +303,7 @@
"filesContainingMalwareModal": {
"title": "Archivos que contienen malware",
"selectedItems": "Seleccionados {{selectedFiles}} de {{totalFiles}}",
- "selectAll": "Seleccionar todo",
- "actions": {
- "cancel": "Cancelar",
- "remove": "Eliminar"
- }
+ "selectAll": "Seleccionar todo"
}
},
"cleaner": {
@@ -432,12 +327,6 @@
"saveUpTo": "Ahorra hasta",
"ofYourSpace": "de tu espacio"
},
- "cleanupConfirmDialog": {
- "title": "Confirmar borrado",
- "description": "Esta acción eliminará permanentemente los archivos seleccionados de tu dispositivo. Esta acción no se puede deshacer. Confirme para continuar.",
- "cancelButton": "Cancelar",
- "confirmButton": "Eliminar archivos"
- },
"cleanupConfirmDialogView": {
"title": "Confirmar limpieza",
"description": "Esta acción eliminará de forma permanente los archivos seleccionados de tu dispositivo. Esta acción no se puede deshacer. Confirma para continuar.",
@@ -471,9 +360,7 @@
},
"no-issues": "No se han encontrado errores",
"actions": {
- "select-folder": "Seleccionar carpeta",
- "find-folder": "Buscar la carpeta",
- "try-again": "Volver a intentar"
+ "find-folder": "Buscar la carpeta"
},
"short-error-messages": {
"unknown": "Error desconocido",
@@ -498,41 +385,9 @@
"insufficient-permission-accessing-base-directory": "Internxt App no tiene permiso para acceder a su carpeta de sincronización",
"cannot-access-base-directory": "No hemos podido acceder a su carpeta local",
"cannot-access-tmp-directory": "No hemos podido acceder a su carpeta local",
- "unknown": "Error desconocido al intentar sincronizar sus archivos",
- "empty-file": "No admitimos archivos con un tamaño de 0 bytes debido a nuestros procesos de cifrado",
- "bad-response": "Error de servidor al procesar este archivo. Por favor, intente iniciar de nuevo el proceso de sincronización",
- "file-does-not-exist": "Este archivo estaba presente cuando comparamos su carpeta local con su unidad Internxt, pero desapareció cuando intentamos acceder a él. Si has eliminado este archivo, no te preocupes, este error debería desaparecer la próxima vez que se inicie el proceso de sincronización",
- "file-too-big": "El tamaño máximo de carga es de 20GB. Por favor, intenta con archivos más pequeños.",
- "file-non-extension": "Los archivos sin extensiones no son soportados. No sincronizado",
- "duplicated-node": "Hay dos elementos (archivo o carpeta) con el mismo nombre en una carpeta. Cambia el nombre de uno de ellos para sincronizar ambos.",
- "action-not-permitted": "La operación no pudo completarse, posiblemente debido a un conflicto con otro archivo.",
- "file-already-exists": "No se puede completar la operación. El archivo ya existe en los servidores de Internxt.",
- "not-enough-space": "No tienes suficiente espacio para completar la operación."
- },
- "report-modal": {
- "actions": {
- "close": "Cerrar",
- "cancel": "Cancelar",
- "report": "Informar",
- "send": "Enviar"
- },
- "help-url": "Para obtener ayuda, visita",
- "report": "También puedes enviar un informe sobre este error",
- "user-comments": "Comentarios",
- "include-logs": "Incluir los registros de este proceso de sincronización con fines de solucionar el error"
+ "unknown": "Error desconocido al intentar sincronizar sus archivos"
}
},
- "feedback": {
- "window-title": "Comentarios sobre Internxt para Escritorio",
- "title": "Comparte tus opiniones con Internxt",
- "description": "Tus comentarios hacen que mejoremos y creemos mejores experiencias de producto",
- "placeholder": "Haznos saber lo que tienes en mente, lo que te gustaría mejorar o describe el error o problema",
- "characters-count": "{{character_count}}/{{character_limit}}",
- "send-feedback": "Enviar comentarios",
- "sent-title": "Gracias por compartir tus comentarios",
- "sent-message": "Apreciamos tu tiempo y esfuerzo para ayudarnos a mejorar nuestros servicios.",
- "close": "Cerrar"
- },
"common": {
"cancel": "Cancelar"
},
diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json
index cdc4faafa0..b149168d5c 100644
--- a/src/apps/renderer/localize/locales/fr.json
+++ b/src/apps/renderer/localize/locales/fr.json
@@ -1,35 +1,11 @@
{
"login": {
- "email": {
- "section": "Adresse électronique"
- },
- "password": {
- "section": "Mot de passe",
- "placeholder": "Mot de passe",
- "forgotten": "Vous avez oublié votre mot de passe?",
- "hide": "Cacher",
- "show": "Afficher"
- },
"action": {
- "login": "S'identifier",
- "is-logging-in": "Se connecter...",
"login-in-browser": "Se connecter avec le navigateur"
},
"create-account": "Créer un compte",
"welcome": "Bienvenue chez Internxt",
- "no-account": "Vous n'avez pas de compte ?",
- "2fa": {
- "section": "Code d'authentification",
- "description": "Vous avez configuré l'authentification en deux étapes (2FA), veuillez saisir le code à 6 chiffres",
- "change-account": "Changer de compte",
- "wrong-code": "Code incorrect, veuillez réessayer"
- },
- "error": {
- "empty-fields": "Mot de passe ou courriel incorrect"
- },
- "warning": {
- "no-internet": "Pas de connexion internet"
- }
+ "no-account": "Vous n'avez pas de compte ?"
},
"onboarding": {
"slides": {
@@ -68,48 +44,7 @@
"continue": "Continuer",
"skip": "Sauter",
"new": "Nouveau",
- "platform-phrase": {
- "windows": "navigateur de fichiers",
- "linux": "navigateur de fichiers",
- "macos": "Finder"
- }
- }
- },
- "migration": {
- "slides": {
- "welcome": {
- "title": "Nouvelle mise à jour de l'application de bureau d’Internxt !",
- "features": {
- "title": "Nouvelles mises à jour:",
- "feature-1": "Sélectionnez ce que vous voulez télécharger et économisez de l'espace sur votre disque dur.",
- "feature-2": "Un système d'exploitation natif disponible pour gérer vos fichiers et dossiers."
- }
- },
- "migration": {
- "title": "Nous nous assurons que tous vos fichiers sont en sécurité",
- "in-progress": "Téléchargement de fichiers en attente",
- "item-progress": "{{processed_items}} sur {{total_items}} éléments téléchargés"
- },
- "migration-failed": {
- "title": "Nous nous assurons que tous vos fichiers sont en sécurité",
- "message": "Certains fichiers n'ont pas pu être téléchargés",
- "description": "Nous avons déplacé ces fichiers sur votre bureau, faites-les glisser et déposez-les sur votre disque interne.",
- "show-files": "Afficher les fichiers"
- },
- "delete-old-drive-folder": {
- "title": "Même Internxt Drive, nouvel emplacement",
- "message": "Votre dossier personnel Internxt Drive est situé dans le barre latérale {{platform_app}}."
- },
- "new-widget": {
- "title": "Soyez plus productif grâce à notre widget redessiné",
- "message": "Nous avons repensé et reconstruit notre widget afin d'accroître la productivité, la commodité et la rapidité.",
- "message-2": "Tous les changements sont désormais mis à jour en temps réel."
- }
- },
- "common": {
- "continue": "Continuer",
- "cancel": "Annuler",
- "open-drive": "Ouvrir Internxt Drive"
+ "platform-phrase": "navigateur de fichiers"
}
},
"widget": {
@@ -121,11 +56,10 @@
"dropdown": {
"preferences": "Préférences",
"issues": "Liste d'erreurs",
- "send-feedback": "Envoyer des commentaires",
"support": "Aide",
+ "referAndEarn": "Parrainez et gagnez",
"logout": "Déconnecter",
"quit": "Fermer",
- "antivirus": "Antivirus",
"cleaner": "Cleaner",
"new": "Nouveau",
"sync": "Synchroniser"
@@ -146,22 +80,12 @@
"renamed": "Renommé"
}
},
- "no-activity": {
- "title": "Aucune activité récente",
- "description": "Les informations apparaîtront ici lorsque vous effectuerez des modifications, pour synchroniser votre dossier local avec Internxt Drive"
- },
"upToDate": {
"title": "Vos fichiers sont à jour",
"subtitle": "L'activité de synchronisation s'affichera ici"
},
"errors": {
- "sync": {},
- "backups": {
- "folder-not-found": {
- "text": "Impossible de copier, dossier non trouvé",
- "action": "Afficher l'erreur"
- }
- }
+ "sync": {}
}
},
"footer": {
@@ -171,7 +95,6 @@
"failed": "Échec de la synchronisation"
},
"errors": {
- "lock": "Synchronisation bloquée par un autre dispositif",
"offline": "Pas de connexion à internet"
}
},
@@ -182,9 +105,7 @@
},
"virtual-drive-error": {
"title": "Impossible de créer le lecteur",
- "message": "Nous rencontrons des problèmes pour monter votre disque Internxt. Essayez de le démonter manuellement et de relancer l'application. ",
- "mounting": "Montage...",
- "button": "Monter"
+ "message": "Nous rencontrons des problèmes pour monter votre disque Internxt. Essayez de le démonter manuellement et de relancer l'application. "
},
"banners": {
"update-available": {
@@ -234,13 +155,8 @@
"dark": "Sombre"
}
},
- "sync": {
- "folder": "Dossier Internxt Drive",
- "change-folder": "Changer de dossier"
- },
"app-info": {
"open-logs": "Ouvrir les registres",
- "open-migration": "Démarrer la migration",
"more": "Plus d'informations sur Internxt"
}
},
@@ -250,17 +166,11 @@
"display": "Utilisé {{used}} sur {{total}}",
"upgrade": "Acheter",
"change": "Changement",
- "plan": "Plan actuel",
"free": "Gratuit",
"loadError": {
"title": "Impossible d'obtenir les détails de votre utilisation",
"action": "Réessayer"
},
- "current": {
- "used": "utilisés",
- "of": "de",
- "in-use": "utilisé"
- },
"full": {
"title": "Votre espace de stockage est plein",
"subtitle": "Vous ne pouvez pas télécharger, synchroniser ou sauvegarder des fichiers. Mettez votre forfait à niveau ou supprimez des fichiers pour économiser de l'espace."
@@ -278,20 +188,17 @@
"add-folders": "Cliquez sur + pour sélectionner les dossiers que vous souhaitez sauvegarder",
"selected-folder_one": "{{count}} dossier",
"selected-folder_other": "{{count}} dossiers",
- "activate": "Sauvegarder vos dossiers",
"view-backups": "Parcourir les fichiers",
"selected-folders-title": "Dossiers sélectionnés",
"select-folders": "Changer les dossiers",
"last-backup-had-issues": "La dernière sauvegarde a rencontré quelques problèmes",
"see-issues": "Voir des problèmes",
- "backing-up": "Sauvegarde...",
"backups-help": "Aide sur les sauvegardes",
"this-device": "Cet appareil",
"devices": "Appareils",
"action": {
"start": "Faire une copie ",
"stop": "Arrêter la sauvegarde",
- "running": "Sauvegarde en cours {{progress}}",
"last-run": "Dernière exécution"
},
"frequency": {
@@ -334,12 +241,6 @@
"title": "Une erreur s'est produite lors de l'analyse du répertoire",
"button": "Réessayer"
},
- "deactivateAntivirus": {
- "title": "Windows Defender est actif",
- "description": "Veuillez désactiver Windows Defender afin de pouvoir utiliser Internxt Antivirus. Pour ce faire, ouvrez Sécurité Windows > Protection contre les virus et menaces > Gérer les paramètres > désactivez la protection en temps réel.",
- "retry": "Réessayer",
- "cancel": "Annuler"
- },
"realtimeProtection": {
"title": "Protection en temps réel",
"infoAriaLabel": "À propos de la protection en temps réel",
@@ -380,8 +281,7 @@
},
"securityWarning": {
"title": "Attention de sécurité",
- "description": "Le malware est toujours présent et votre appareil est en danger.",
- "confirmToCancel": "Êtes-vous sûr de vouloir annuler ?"
+ "description": "Le malware est toujours présent et votre appareil est en danger."
}
}
},
@@ -389,7 +289,6 @@
"scanning": "Analyse en cours...",
"scannedFiles": "Fichiers analysés",
"detectedFiles": "Fichiers détectés",
- "errorWhileScanning": "Une erreur s'est produite lors de l'analyse des éléments. Veuillez réessayer.",
"noFilesFound": {
"title": "Aucune menace détectée",
"subtitle": "Aucune action supplémentaire requise"
@@ -404,11 +303,7 @@
"filesContainingMalwareModal": {
"title": "Fichiers contenant des malwares",
"selectedItems": "Sélectionné {{selectedFiles}} sur {{totalFiles}}",
- "selectAll": "Tout sélectionner",
- "actions": {
- "cancel": "Annuler",
- "remove": "Supprimer"
- }
+ "selectAll": "Tout sélectionner"
}
},
"cleaner": {
@@ -432,12 +327,6 @@
"saveUpTo": "Économisez jusqu'à",
"ofYourSpace": "de votre espace"
},
- "cleanupConfirmDialog": {
- "title": "Confirmer le nettoyage",
- "description": "Cette action supprimera définitivement les fichiers sélectionnés de votre appareil. Cette action ne peut pas être annulée. Veuillez confirmer pour continuer.",
- "cancelButton": "Annuler",
- "confirmButton": "Supprimer les fichiers "
- },
"cleanupConfirmDialogView": {
"title": "Confirmer le nettoyage",
"description": "Cette action supprimera définitivement les fichiers sélectionnés de votre appareil. Cette action ne peut pas être annulée. Veuillez confirmer pour continuer.",
@@ -471,9 +360,7 @@
},
"no-issues": "Aucune erreur trouvée",
"actions": {
- "select-folder": "Sélectionner un dossier",
- "find-folder": "Trouver un dossier",
- "try-again": "Essayer à nouveau"
+ "find-folder": "Trouver un dossier"
},
"short-error-messages": {
"unknown": "Erreur inconnue",
@@ -498,41 +385,9 @@
"insufficient-permission-accessing-base-directory": "Internxt App n'a pas la permission d'accéder à votre dossier de synchronisation",
"cannot-access-base-directory": "Nous n'avons pas pu accéder à votre dossier local",
"cannot-access-tmp-directory": "Nous n'avons pas pu accéder à votre dossier local",
- "unknown": "Une erreur inconnue s'est produite lors de la synchronisation de vos fichiers",
- "empty-file": "Nous ne prenons pas en charge les fichiers d'une taille de 0 octet en raison de nos processus de chiffrement",
- "bad-response": "Nous avons reçu une mauvaise réponse de nos serveurs lors du traitement de ce fichier. Veuillez essayer de relancer le processus de synchronisation.",
- "file-does-not-exist": "Ce fichier était présent lorsque nous avons comparé votre dossier local avec votre disque interne, mais il a disparu lorsque nous avons essayé d'y accéder. Si vous avez supprimé ce fichier, ne vous inquiétez pas, cette erreur devrait disparaître au prochain démarrage du processus de synchronisation.",
- "file-too-big": "La taille maximale de téléchargement est de 20 GB. Veuillez essayer des fichiers plus petits.",
- "file-non-extension": "Les archives sans extensions ne sont pas supportées. Non synchronisées",
- "duplicated-node": "Il y a deux éléments (fichier ou dossier) avec le même nom dans un dossier. Renommez l'un d'eux pour les synchroniser tous les deux.",
- "action-not-permitted": "L'opération n'a pas pu être complétée, probablement en raison d'un conflit avec un autre fichier.",
- "file-already-exists": "Impossible de terminer l'opération. Le fichier existe déjà sur les serveurs Internxt.",
- "not-enough-space": "Vous n'avez pas assez d'espace pour compléter l'opération."
- },
- "report-modal": {
- "actions": {
- "close": "Fermer",
- "cancel": "Annuler",
- "report": "Rapport",
- "send": "Envoyer"
- },
- "help-url": "Pour obtenir de l'aide, visitez",
- "report": "Vous pouvez également envoyer un rapport sur cette erreur",
- "user-comments": "Commentaires",
- "include-logs": "Inclure les logs de ce processus de synchronisation à des fins de diagnostic"
+ "unknown": "Une erreur inconnue s'est produite lors de la synchronisation de vos fichiers"
}
},
- "feedback": {
- "window-title": "Commentaires sur Internxt for Desktop",
- "title": "Faites-nous part de vos commentaires sur Internxt",
- "description": "Vos commentaires nous aident à améliorer et à créer de meilleures expériences de produits.",
- "placeholder": "Laissez-nous savoir ce qui vous préoccupe, ce que vous aimeriez améliorer ou décrivez l'erreur ou le problème.",
- "characters-count": "{{character_count}}/{{character_limit}}",
- "send-feedback": "Envoyer les commentaire",
- "sent-title": "Merci de nous avoir fait part de vos commentaires",
- "sent-message": "Nous apprécions le temps et les efforts que vous consacrez à l'amélioration de nos services.",
- "close": "Fermer"
- },
"common": {
"cancel": "Annuler"
},
diff --git a/src/apps/renderer/pages/Login/index.tsx b/src/apps/renderer/pages/Login/index.tsx
index e9ff0c0535..a665250630 100644
--- a/src/apps/renderer/pages/Login/index.tsx
+++ b/src/apps/renderer/pages/Login/index.tsx
@@ -12,7 +12,10 @@ export default function Login() {
setIsLoading(true);
await window.electron.openUrl(URL);
} catch (error) {
- console.error('Error opening URL:', error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open URL from login screen',
+ error,
+ });
} finally {
setIsLoading(false);
}
@@ -28,7 +31,7 @@ export default function Login() {
return (
-
+
diff --git a/src/apps/renderer/pages/Onboarding/helpers.tsx b/src/apps/renderer/pages/Onboarding/helpers.tsx
index 6bf237459d..f20919608b 100644
--- a/src/apps/renderer/pages/Onboarding/helpers.tsx
+++ b/src/apps/renderer/pages/Onboarding/helpers.tsx
@@ -9,7 +9,6 @@ export type OnboardingSlideProps = {
backupFolders: BackupFolder[];
currentSlide: number;
totalSlides: number;
- platform: string;
};
export type OnboardingSlide = {
diff --git a/src/apps/renderer/pages/Onboarding/index.tsx b/src/apps/renderer/pages/Onboarding/index.tsx
index b7975673b3..db0c4c9505 100644
--- a/src/apps/renderer/pages/Onboarding/index.tsx
+++ b/src/apps/renderer/pages/Onboarding/index.tsx
@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import { SLIDES } from './config';
import { BackupFolder, BackupsFoldersSelector } from '../../components/Backups/BackupsFoldersSelector';
-import useClientPlatform from '../../hooks/ClientPlatform';
// Slide 1 is welcome slide, last slide is summary, doesn't count
const totalSlides = SLIDES.length - 2;
@@ -10,7 +9,6 @@ export default function Onboarding() {
const [backupFolders, setBackupFolders] = useState
([]);
const [slideIndex, setSlideIndex] = useState(0);
const [backupsModalOpen, setBackupsModalOpen] = useState(false);
- const desktopPlatform = useClientPlatform();
const finish = () => {
if (backupFolders?.length) {
@@ -19,9 +17,13 @@ export default function Onboarding() {
* if this fails, the user can fix this
* from the Desktop settings
*/
- window.electron.addBackupsFromLocalPaths(backupFolders.map((backupFolder) => backupFolder.path)).catch((err) => {
- reportError(err);
- });
+ window.electron
+ .addBackupsFromLocalPaths(backupFolders.map((backupFolder) => backupFolder.path))
+ .then(({ error }) => {
+ if (error) {
+ window.electron.logger.error({ msg: 'Failed to add backup folders during onboarding', error });
+ }
+ });
}
window.electron.finishOnboarding();
@@ -64,12 +66,10 @@ export default function Onboarding() {
}, 300);
};
- if (!desktopPlatform) return <>>;
return (
= () => {
{translate('onboarding.slides.drive.description', {
- platform_app: translate('onboarding.common.platform-phrase.windows'),
+ platform_app: translate('onboarding.common.platform-phrase'),
})}
diff --git a/src/apps/renderer/pages/Onboarding/slides/onboarding-completed-slide.tsx b/src/apps/renderer/pages/Onboarding/slides/onboarding-completed-slide.tsx
index e916aea62a..2a8558886f 100644
--- a/src/apps/renderer/pages/Onboarding/slides/onboarding-completed-slide.tsx
+++ b/src/apps/renderer/pages/Onboarding/slides/onboarding-completed-slide.tsx
@@ -23,7 +23,7 @@ export const OnboardingCompletedSlide: React.FC
= () => {
{translate('onboarding.slides.onboarding-completed.desktop-ready.description', {
- platform_phrase: translate('onboarding.common.platform-phrase.windows'),
+ platform_phrase: translate('onboarding.common.platform-phrase'),
})}
diff --git a/src/apps/renderer/pages/Settings/Account/Usage.tsx b/src/apps/renderer/pages/Settings/Account/Usage.tsx
index c563fcbbf3..0a48d9ab84 100644
--- a/src/apps/renderer/pages/Settings/Account/Usage.tsx
+++ b/src/apps/renderer/pages/Settings/Account/Usage.tsx
@@ -13,9 +13,9 @@ export default function Usage({ isInfinite, offerUpgrade, usageInBytes, limitInB
if (isInfinite) {
return { amount: '∞', unit: '' };
} else {
- const amount = bytes.format(limitInBytes).match(/\d+/g)?.[0] ?? '';
- const unit = bytes.format(limitInBytes).match(/[a-zA-Z]+/g)?.[0] ?? '';
- return { amount: amount, unit: unit };
+ const amount = bytes.format(limitInBytes)?.match(/\d+/g)?.[0] ?? '';
+ const unit = bytes.format(limitInBytes)?.match(/[a-zA-Z]+/g)?.[0] ?? '';
+ return { amount, unit };
}
};
@@ -23,7 +23,10 @@ export default function Usage({ isInfinite, offerUpgrade, usageInBytes, limitInB
try {
await window.electron.openUrl('https://drive.internxt.com/preferences?tab=plans');
} catch (error) {
- reportError(error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open upgrade URL from usage section',
+ error,
+ });
}
};
@@ -52,8 +55,8 @@ export default function Usage({ isInfinite, offerUpgrade, usageInBytes, limitInB
{translate('settings.account.usage.display', {
- used: bytes.format(usageInBytes),
- total: bytes.format(limitInBytes),
+ used: bytes.format(usageInBytes) || '0 B',
+ total: bytes.format(limitInBytes) || '0 B',
})}
diff --git a/src/apps/renderer/pages/Settings/Antivirus/components/CustomScanItemsSelectorDropdown.test.tsx b/src/apps/renderer/pages/Settings/Antivirus/components/CustomScanItemsSelectorDropdown.test.tsx
index 067132b28c..7644d6879c 100644
--- a/src/apps/renderer/pages/Settings/Antivirus/components/CustomScanItemsSelectorDropdown.test.tsx
+++ b/src/apps/renderer/pages/Settings/Antivirus/components/CustomScanItemsSelectorDropdown.test.tsx
@@ -3,7 +3,7 @@ import { CustomScanItemsSelectorDropdown } from './CustomScanItemsSelectorDropdo
// Mock the DropdownItem component
vi.mock('./DropdownItem', () => ({
- DropdownItem: ({ children, onClick }: any) => (
+ DropdownItem: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
diff --git a/src/apps/renderer/pages/Settings/Antivirus/views/LockedState.tsx b/src/apps/renderer/pages/Settings/Antivirus/views/LockedState.tsx
index 4f19bfe081..00d27e3ea2 100644
--- a/src/apps/renderer/pages/Settings/Antivirus/views/LockedState.tsx
+++ b/src/apps/renderer/pages/Settings/Antivirus/views/LockedState.tsx
@@ -9,7 +9,10 @@ export const LockedState = () => {
try {
await window.electron.openUrl('https://internxt.com/pricing');
} catch (error) {
- reportError(error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open antivirus pricing page',
+ error,
+ });
}
};
diff --git a/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.test.tsx b/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.test.tsx
index 0ee708653e..e47902afe4 100644
--- a/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.test.tsx
+++ b/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.test.tsx
@@ -1,4 +1,4 @@
-import { Device } from '../../../../../main/device/service';
+import { Device } from '../../../../../../backend/features/backup/types/Device';
import { screen, render, fireEvent } from '@testing-library/react';
import DevicePill from './DevicePill';
@@ -19,7 +19,7 @@ const mockDevice: Device = {
describe('DevicePill', () => {
afterAll(() => {
- // @ts-ignore
+ // @ts-expect-error - window.electron is defined by preload and not deletable by type
delete window.electron;
});
diff --git a/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.tsx b/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.tsx
index 9e50f60ecd..a9a3950c1d 100644
--- a/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.tsx
+++ b/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicePill.tsx
@@ -1,4 +1,4 @@
-import { Device } from '../../../../../main/device/service';
+import { Device } from '../../../../../../backend/features/backup/types/Device';
import { type FC } from 'react';
import { useTranslationContext } from '../../../../context/LocalContext';
diff --git a/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicesList.test.tsx b/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicesList.test.tsx
index e715577e66..a3706d94cb 100644
--- a/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicesList.test.tsx
+++ b/src/apps/renderer/pages/Settings/Backups/DevicesList/DevicesList.test.tsx
@@ -1,4 +1,4 @@
-import { Device } from '../../../../../main/device/service';
+import { Device } from '../../../../../../backend/features/backup/types/Device';
import { fireEvent, render, screen } from '@testing-library/react';
import { DeviceContext, DeviceState } from '../../../../context/DeviceContext';
import { DevicesList } from './DevicesList';
@@ -73,7 +73,7 @@ describe('DevicesList', () => {
});
afterAll(() => {
- // @ts-ignore
+ // @ts-expect-error - window.electron is defined by preload and not deletable by type
delete window.electron;
});
diff --git a/src/apps/renderer/pages/Settings/Backups/DevicesList/Help.tsx b/src/apps/renderer/pages/Settings/Backups/DevicesList/Help.tsx
index d8888be670..462f385a62 100644
--- a/src/apps/renderer/pages/Settings/Backups/DevicesList/Help.tsx
+++ b/src/apps/renderer/pages/Settings/Backups/DevicesList/Help.tsx
@@ -10,7 +10,10 @@ const Help: FC = () => {
'https://help.internxt.com/en/articles/6583477-how-do-backups-work-on-internxt-drive',
);
} catch (error) {
- reportError(error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open backups help URL',
+ error,
+ });
}
};
diff --git a/src/apps/renderer/pages/Settings/Backups/DownloadBackups.tsx b/src/apps/renderer/pages/Settings/Backups/DownloadBackups.tsx
index 76a04fb2f2..fec9360841 100644
--- a/src/apps/renderer/pages/Settings/Backups/DownloadBackups.tsx
+++ b/src/apps/renderer/pages/Settings/Backups/DownloadBackups.tsx
@@ -11,18 +11,23 @@ export function DownloadBackups({ className }: ViewBackupsProps) {
useContext(BackupContext);
const handleDownloadBackup = async () => {
+ if (!selected) return;
+
if (!thereIsDownloadProgress) {
- await downloadBackups(selected!);
- } else {
- try {
- abortDownloadBackups(selected!);
- } catch (err) {
- // error while aborting (aborting also throws an exception itself)
- } finally {
- setTimeout(() => {
- clearBackupDownloadProgress(selected!.uuid);
- }, 600);
- }
+ const chosenFolder = await window.electron.getFolderPath();
+ if (!chosenFolder) return;
+ await downloadBackups(selected, chosenFolder.path);
+ return;
+ }
+
+ try {
+ abortDownloadBackups(selected);
+ } catch (err) {
+ // error while aborting (aborting also throws an exception itself)
+ } finally {
+ setTimeout(() => {
+ clearBackupDownloadProgress(selected.uuid);
+ }, 600);
}
};
diff --git a/src/apps/renderer/pages/Settings/Backups/ViewBackups.tsx b/src/apps/renderer/pages/Settings/Backups/ViewBackups.tsx
index 8ac5c49dd9..c17a375d91 100644
--- a/src/apps/renderer/pages/Settings/Backups/ViewBackups.tsx
+++ b/src/apps/renderer/pages/Settings/Backups/ViewBackups.tsx
@@ -10,7 +10,10 @@ export function ViewBackups({ className }: ViewBackupsProps) {
try {
await window.electron.openUrl('https://drive.internxt.com/app/backups');
} catch (error) {
- reportError(error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open backups page URL',
+ error,
+ });
}
};
diff --git a/src/apps/renderer/pages/Settings/General/AppInfo.tsx b/src/apps/renderer/pages/Settings/General/AppInfo.tsx
index 551677fa80..06740d0e56 100644
--- a/src/apps/renderer/pages/Settings/General/AppInfo.tsx
+++ b/src/apps/renderer/pages/Settings/General/AppInfo.tsx
@@ -8,7 +8,10 @@ export default function AppInfo() {
try {
await window.electron.openUrl(URL);
} catch (error) {
- reportError(error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open URL from app info',
+ error,
+ });
}
};
diff --git a/src/apps/renderer/pages/Widget/AccountSection.test.tsx b/src/apps/renderer/pages/Widget/AccountSection.test.tsx
index c7c4aaefa1..9c918ec35e 100644
--- a/src/apps/renderer/pages/Widget/AccountSection.test.tsx
+++ b/src/apps/renderer/pages/Widget/AccountSection.test.tsx
@@ -3,6 +3,7 @@ import { type Mock } from 'vitest';
import { useTranslationContext } from '../../context/LocalContext';
import { useUsage } from '../../context/UsageContext/useUsage';
import { AccountSection } from './AccountSection';
+import { type User } from '../../../main/types';
vi.mock('../../context/LocalContext');
vi.mock('../../context/UsageContext/useUsage');
@@ -13,7 +14,7 @@ describe('AccountSection', () => {
beforeEach(() => {
vi.clearAllMocks();
(useTranslationContext as Mock).mockReturnValue({ translate: (key: string) => key });
- getUserMock.mockResolvedValue(null as any);
+ getUserMock.mockResolvedValue(null);
});
it('renders the account section container', () => {
@@ -26,7 +27,11 @@ describe('AccountSection', () => {
it('shows user initials when user is loaded', async () => {
(useUsage as Mock).mockReturnValue({ status: 'ready', usage: null });
- getUserMock.mockResolvedValue({ name: 'John', lastname: 'Doe', email: 'john@example.com' } as any);
+ getUserMock.mockResolvedValue({
+ name: 'John',
+ lastname: 'Doe',
+ email: 'john@example.com',
+ } as Partial
as User);
render();
@@ -35,7 +40,11 @@ describe('AccountSection', () => {
it('shows user email when user is loaded', async () => {
(useUsage as Mock).mockReturnValue({ status: 'ready', usage: null });
- getUserMock.mockResolvedValue({ name: 'John', lastname: 'Doe', email: 'john@example.com' } as any);
+ getUserMock.mockResolvedValue({
+ name: 'John',
+ lastname: 'Doe',
+ email: 'john@example.com',
+ } as Partial as User);
render();
diff --git a/src/apps/renderer/pages/Widget/Header.tsx b/src/apps/renderer/pages/Widget/Header.tsx
index ff010b430a..e6bb877f12 100644
--- a/src/apps/renderer/pages/Widget/Header.tsx
+++ b/src/apps/renderer/pages/Widget/Header.tsx
@@ -20,7 +20,10 @@ export default function Header() {
try {
await window.electron.openUrl(URL);
} catch (error) {
- reportError(error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open URL from widget header',
+ error,
+ });
}
};
diff --git a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx
index 71a246cebf..8cab6ff104 100644
--- a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx
+++ b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx
@@ -145,4 +145,15 @@ describe('ItemsSection', () => {
expect(window.electron.logout).toHaveBeenCalledOnce();
});
+
+ it('calls onOpenURL with referral URL when refer and earn is clicked', () => {
+ const onOpenURL = vi.fn();
+
+ render();
+
+ const referButton = screen.getByText('widget.header.dropdown.referAndEarn').closest('button')!;
+ fireEvent.click(referButton);
+
+ expect(onOpenURL).toHaveBeenCalledWith('https://internxt.com/refer-friends');
+ });
});
diff --git a/src/apps/renderer/pages/Widget/ItemsSection.tsx b/src/apps/renderer/pages/Widget/ItemsSection.tsx
index 2a84b21dab..22dd45d682 100644
--- a/src/apps/renderer/pages/Widget/ItemsSection.tsx
+++ b/src/apps/renderer/pages/Widget/ItemsSection.tsx
@@ -20,7 +20,12 @@ export function ItemsSection({ numberOfIssues, numberOfIssuesDisplay, onQuitClic
const handleManualSync = () => {
if (isSyncing) return;
- window.electron.startRemoteSync().catch(reportError);
+ window.electron.startRemoteSync().catch((error) => {
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to start manual sync from widget menu',
+ error,
+ });
+ });
};
return (
@@ -116,6 +121,18 @@ export function ItemsSection({ numberOfIssues, numberOfIssuesDisplay, onQuitClic
)}
+
+ {({ active }) => (
+
+ onOpenURL('https://internxt.com/refer-friends')}
+ data-automation-id="menuItemReferAndEarn">
+ {translate('widget.header.dropdown.referAndEarn')}
+
+
+ )}
+
{({ active }) => (
diff --git a/src/apps/renderer/pages/Widget/SyncAction.tsx b/src/apps/renderer/pages/Widget/SyncAction.tsx
index ab10bd42c8..1051c9b636 100644
--- a/src/apps/renderer/pages/Widget/SyncAction.tsx
+++ b/src/apps/renderer/pages/Widget/SyncAction.tsx
@@ -20,7 +20,10 @@ export default function SyncAction(props: { syncStatus: SyncStatus }) {
try {
await window.electron.openUrl('https://drive.internxt.com/preferences?tab=plans');
} catch (error) {
- reportError(error);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to open upgrade URL from widget sync action',
+ error,
+ });
}
};
diff --git a/src/apps/renderer/pages/Widget/index.tsx b/src/apps/renderer/pages/Widget/index.tsx
index e49df69d51..2c130af03b 100644
--- a/src/apps/renderer/pages/Widget/index.tsx
+++ b/src/apps/renderer/pages/Widget/index.tsx
@@ -11,7 +11,10 @@ import { InfoBanners } from './InfoBanners/InfoBanners';
const handleRetrySync = () => {
window.electron.startRemoteSync().catch((err) => {
- reportError(err);
+ window.electron.logger.error({
+ msg: '[RENDERER] Failed to retry sync from widget',
+ error: err,
+ });
});
};
diff --git a/src/apps/shared/IPC/TypedIPC.ts b/src/apps/shared/IPC/TypedIPC.ts
index 7b2f1829af..733b1e5d5e 100644
--- a/src/apps/shared/IPC/TypedIPC.ts
+++ b/src/apps/shared/IPC/TypedIPC.ts
@@ -1,6 +1,6 @@
import { IpcMainEvent } from 'electron';
-type EventHandler = (...args: any) => any;
+type EventHandler = (...args: unknown[]) => unknown;
type CustomIPCEvents = Record
;
diff --git a/src/apps/shared/dependency-injection/main/mainProcessSharedInfraContainer.ts b/src/apps/shared/dependency-injection/main/mainProcessSharedInfraContainer.ts
index 52339a8dd7..ec0eddac90 100644
--- a/src/apps/shared/dependency-injection/main/mainProcessSharedInfraContainer.ts
+++ b/src/apps/shared/dependency-injection/main/mainProcessSharedInfraContainer.ts
@@ -14,7 +14,7 @@ export async function mainProcessSharedInfraBuilder(): Promise
builder.register(UploadProgressTracker).use(MainProcessUploadProgressTracker).private();
- builder.register(RemoteItemsGenerator).use(SQLiteRemoteItemsGenerator).private();
+ builder.register(RemoteItemsGenerator).use(SQLiteRemoteItemsGenerator);
return builder;
}
diff --git a/src/backend/common/rate-limit/constants.ts b/src/backend/common/rate-limit/constants.ts
new file mode 100644
index 0000000000..c7bed5179c
--- /dev/null
+++ b/src/backend/common/rate-limit/constants.ts
@@ -0,0 +1,3 @@
+export const INITIAL_RATE_LIMIT_DELAY_MS = 30_000;
+export const INITIAL_SERVER_ERROR_DELAY_MS = 1_000;
+export const MAX_BACKOFF_MS = 480_000;
diff --git a/src/backend/features/backup/upload/backup-upload-error-handler.test.ts b/src/backend/common/rate-limit/transient-error-handler.test.ts
similarity index 59%
rename from src/backend/features/backup/upload/backup-upload-error-handler.test.ts
rename to src/backend/common/rate-limit/transient-error-handler.test.ts
index 18f21506ea..4199b0e729 100644
--- a/src/backend/features/backup/upload/backup-upload-error-handler.test.ts
+++ b/src/backend/common/rate-limit/transient-error-handler.test.ts
@@ -1,10 +1,10 @@
-import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
-import { createBackupUploadErrorHandler } from './backup-upload-error-handler';
-import { INITIAL_RATE_LIMIT_DELAY_MS, MAX_BACKOFF_MS, RETRY_DELAYS_MS } from './constants';
+import { DriveDesktopError } from '../../../context/shared/domain/errors/DriveDesktopError';
+import { createTransientErrorHandler } from './transient-error-handler';
+import { INITIAL_RATE_LIMIT_DELAY_MS, INITIAL_SERVER_ERROR_DELAY_MS, MAX_BACKOFF_MS } from './constants';
-describe('createBackupUploadErrorHandler', () => {
+describe('createTransientErrorHandler', () => {
it('should return null for non-retryable errors', () => {
- const handler = createBackupUploadErrorHandler('/file.txt');
+ const handler = createTransientErrorHandler({ tag: 'BACKUPS', context: 'TEST', path: '/file.txt' });
expect(handler(new DriveDesktopError('UNKNOWN'))).toBeNull();
expect(handler(new DriveDesktopError('NOT_ENOUGH_SPACE'))).toBeNull();
@@ -12,16 +12,16 @@ describe('createBackupUploadErrorHandler', () => {
});
it('should return exponential backoff delay for INTERNAL_SERVER_ERROR', () => {
- const handler = createBackupUploadErrorHandler('/file.txt');
+ const handler = createTransientErrorHandler({ tag: 'BACKUPS', context: 'TEST', path: '/file.txt' });
const error = new DriveDesktopError('INTERNAL_SERVER_ERROR');
- expect(handler(error)).toBe(RETRY_DELAYS_MS[0] * Math.pow(2, 0)); // attempt 1: 1000ms
- expect(handler(error)).toBe(RETRY_DELAYS_MS[0] * Math.pow(2, 1)); // attempt 2: 2000ms
- expect(handler(error)).toBe(RETRY_DELAYS_MS[0] * Math.pow(2, 2)); // attempt 3: 4000ms
+ expect(handler(error)).toBe(INITIAL_SERVER_ERROR_DELAY_MS * Math.pow(2, 0)); // attempt 1: 1000ms
+ expect(handler(error)).toBe(INITIAL_SERVER_ERROR_DELAY_MS * Math.pow(2, 1)); // attempt 2: 2000ms
+ expect(handler(error)).toBe(INITIAL_SERVER_ERROR_DELAY_MS * Math.pow(2, 2)); // attempt 3: 4000ms
});
it('should cap INTERNAL_SERVER_ERROR delay at MAX_BACKOFF_MS', () => {
- const handler = createBackupUploadErrorHandler('/file.txt');
+ const handler = createTransientErrorHandler({ tag: 'SYNC-ENGINE', context: 'TEST', path: '/file.txt' });
const error = new DriveDesktopError('INTERNAL_SERVER_ERROR');
// base=1000, cap=480000 → attempt 9: 256000ms, attempt 10: 512000ms → capped
@@ -32,14 +32,14 @@ describe('createBackupUploadErrorHandler', () => {
it('should use retry_after from RATE_LIMITED message as base delay', () => {
const retryAfterMs = 60_000;
- const handler = createBackupUploadErrorHandler('/file.txt');
+ const handler = createTransientErrorHandler({ tag: 'BACKUPS', context: 'TEST', path: '/file.txt' });
const error = new DriveDesktopError('RATE_LIMITED', String(retryAfterMs));
expect(handler(error)).toBe(retryAfterMs * Math.pow(2, 0)); // attempt 1: 60000ms
});
it('should fall back to INITIAL_RATE_LIMIT_DELAY_MS when RATE_LIMITED message is not a number', () => {
- const handler = createBackupUploadErrorHandler('/file.txt');
+ const handler = createTransientErrorHandler({ tag: 'SYNC-ENGINE', context: 'TEST', path: '/file.txt' });
const error = new DriveDesktopError('RATE_LIMITED', 'not-a-number');
expect(handler(error)).toBe(INITIAL_RATE_LIMIT_DELAY_MS);
@@ -47,7 +47,7 @@ describe('createBackupUploadErrorHandler', () => {
it('should apply exponential backoff across multiple RATE_LIMITED retries', () => {
const retryAfterMs = 10_000;
- const handler = createBackupUploadErrorHandler('/file.txt');
+ const handler = createTransientErrorHandler({ tag: 'BACKUPS', context: 'TEST', path: '/file.txt' });
const error = new DriveDesktopError('RATE_LIMITED', String(retryAfterMs));
expect(handler(error)).toBe(retryAfterMs * Math.pow(2, 0)); // attempt 1: 10000ms
@@ -55,7 +55,7 @@ describe('createBackupUploadErrorHandler', () => {
});
it('should share attempt counter between RATE_LIMITED and INTERNAL_SERVER_ERROR', () => {
- const handler = createBackupUploadErrorHandler('/file.txt');
+ const handler = createTransientErrorHandler({ tag: 'SYNC-ENGINE', context: 'TEST', path: '/file.txt' });
handler(new DriveDesktopError('INTERNAL_SERVER_ERROR')); // attempt 1, base=1000 → 1000ms
const delay = handler(new DriveDesktopError('RATE_LIMITED', String(INITIAL_RATE_LIMIT_DELAY_MS))); // attempt 2, base=30000 → 60000ms
@@ -64,14 +64,14 @@ describe('createBackupUploadErrorHandler', () => {
});
it('should create independent state per handler instance', () => {
- const handler1 = createBackupUploadErrorHandler('/file1.txt');
- const handler2 = createBackupUploadErrorHandler('/file2.txt');
+ const handler1 = createTransientErrorHandler({ tag: 'BACKUPS', context: 'TEST', path: '/file1.txt' });
+ const handler2 = createTransientErrorHandler({ tag: 'SYNC-ENGINE', context: 'TEST', path: '/file2.txt' });
const error = new DriveDesktopError('INTERNAL_SERVER_ERROR');
handler1(error); // advance handler1 to attempt 1
handler1(error); // advance handler1 to attempt 2
// handler2 should start fresh at attempt 1
- expect(handler2(error)).toBe(RETRY_DELAYS_MS[0]);
+ expect(handler2(error)).toBe(INITIAL_SERVER_ERROR_DELAY_MS);
});
});
diff --git a/src/backend/common/rate-limit/transient-error-handler.ts b/src/backend/common/rate-limit/transient-error-handler.ts
new file mode 100644
index 0000000000..3960829e8f
--- /dev/null
+++ b/src/backend/common/rate-limit/transient-error-handler.ts
@@ -0,0 +1,67 @@
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import { DriveDesktopError } from '../../../context/shared/domain/errors/DriveDesktopError';
+import { extractPropertyFromStringyfiedJson } from '../../../shared/extract-property-from-json';
+import { INITIAL_RATE_LIMIT_DELAY_MS, INITIAL_SERVER_ERROR_DELAY_MS, MAX_BACKOFF_MS } from './constants';
+
+export function parseRetryAfterMs(message?: string) {
+ const retryAfterSeconds = extractPropertyFromStringyfiedJson(message ?? '', 'retry_after');
+ return typeof retryAfterSeconds === 'number' ? retryAfterSeconds * 1000 : INITIAL_RATE_LIMIT_DELAY_MS;
+}
+
+export function mapEnvironmentUploadError(err: Error & { code?: unknown; status?: unknown }): DriveDesktopError {
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
+ return new DriveDesktopError('ACTION_NOT_PERMITTED', err.message);
+ }
+ if (err.message === 'Max space used') {
+ return new DriveDesktopError('NOT_ENOUGH_SPACE');
+ }
+ if (typeof err.status === 'number') {
+ if (err.status === 429) {
+ return new DriveDesktopError('RATE_LIMITED', String(parseRetryAfterMs(err.message)));
+ }
+ if (err.status >= 500) {
+ return new DriveDesktopError('INTERNAL_SERVER_ERROR');
+ }
+ }
+ return new DriveDesktopError('UNKNOWN', err.message);
+}
+
+function exponentialBackoff(attempts: number, baseMs: number) {
+ return Math.min(baseMs * Math.pow(2, attempts - 1), MAX_BACKOFF_MS);
+}
+
+type Props = {
+ tag: 'BACKUPS' | 'SYNC-ENGINE';
+ context: string;
+ path: string;
+};
+
+export function createTransientErrorHandler({ tag, context, path }: Props) {
+ let transientAttempts = 0;
+
+ return (error: DriveDesktopError): number | null => {
+ if (error.cause === 'RATE_LIMITED' || error.cause === 'INTERNAL_SERVER_ERROR') {
+ transientAttempts++;
+
+ const baseDelayMs =
+ error.cause === 'RATE_LIMITED'
+ ? Number(error.message) || INITIAL_RATE_LIMIT_DELAY_MS
+ : INITIAL_SERVER_ERROR_DELAY_MS;
+
+ const delayMs = exponentialBackoff(transientAttempts, baseDelayMs);
+
+ logger.debug({
+ tag,
+ msg: `[${context}]`,
+ cause: error.cause,
+ attempt: transientAttempts,
+ delayMs,
+ path,
+ });
+
+ return delayMs;
+ }
+
+ return null;
+ };
+}
diff --git a/src/apps/main/backups/add-backup.test.ts b/src/backend/features/backup/add-backup.test.ts
similarity index 76%
rename from src/apps/main/backups/add-backup.test.ts
rename to src/backend/features/backup/add-backup.test.ts
index a9300526a0..be01661f5d 100644
--- a/src/apps/main/backups/add-backup.test.ts
+++ b/src/backend/features/backup/add-backup.test.ts
@@ -1,9 +1,10 @@
-import * as getPathFromDialogModule from '../../../backend/features/backup/get-path-from-dialog';
+import * as getPathFromDialogModule from '../../../core/utils/get-path-from-dialog';
import * as createBackupModule from './create-backup';
import * as DeviceModuleModule from './../../../backend/features/device/device.module';
import * as enableExistingBackupModule from './enable-existing-backup';
import * as fetchDeviceModule from '../../../backend/features/device/fetchDevice';
-import configStoreModule from '../config';
+import configStoreModule from '../../../apps/main/config';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
import { addBackup } from './add-backup';
import { loggerMock } from 'tests/vitest/mocks.helper';
import { call, partialSpyOn } from 'tests/vitest/utils.helper';
@@ -33,9 +34,11 @@ describe('addBackup', () => {
const mockError = new Error('Device not found');
mockedGetOrCreateDevice.mockResolvedValue({ error: mockError, data: undefined });
- await expect(addBackup()).rejects.toThrow('Error message');
+ const result = await addBackup();
+
+ expect(result).toMatchObject({ error: expect.any(Error) });
call(loggerMock.error).toMatchObject({
- msg: 'Error adding backup: No device found',
+ msg: 'Error fetching or creating device',
});
});
@@ -45,11 +48,11 @@ describe('addBackup', () => {
const result = await addBackup();
- expect(result).toBeUndefined();
+ expect(result).toMatchObject({ error: expect.any(Error) });
});
it('should create new backup when backup does not exist', async () => {
- const chosenPath = '/path/to/backup';
+ const chosenPath = createAbsolutePath('/path/to/backup');
const mockBackupInfo = {
folderUuid: 'folder-uuid',
folderId: 123,
@@ -62,7 +65,7 @@ describe('addBackup', () => {
mockedGetOrCreateDevice.mockResolvedValue({ error: undefined, data: mockDevice });
mockedGetPathFromDialog.mockResolvedValue({ path: chosenPath, itemName: 'backup' });
mockedConfigStoreGet.mockReturnValue({});
- mockedCreateBackup.mockResolvedValue(mockBackupInfo);
+ mockedCreateBackup.mockResolvedValue({ data: mockBackupInfo } as never);
const result = await addBackup();
@@ -70,11 +73,11 @@ describe('addBackup', () => {
pathname: chosenPath,
device: mockDevice,
});
- expect(result).toStrictEqual(mockBackupInfo);
+ expect(result).toStrictEqual({ data: mockBackupInfo });
});
it('should enable existing backup when backup exists', async () => {
- const chosenPath = '/path/to/existing';
+ const chosenPath = createAbsolutePath('/path/to/existing');
const existingBackupData = {
folderUuid: 'existing-uuid',
folderId: 456,
@@ -92,11 +95,14 @@ describe('addBackup', () => {
mockedGetOrCreateDevice.mockResolvedValue({ error: undefined, data: mockDevice });
mockedGetPathFromDialog.mockResolvedValue({ path: chosenPath, itemName: 'existing' });
mockedConfigStoreGet.mockReturnValue({ [chosenPath]: existingBackupData });
- mockedEnableExistingBackup.mockResolvedValue(mockBackupInfo);
+ mockedEnableExistingBackup.mockResolvedValue({ data: mockBackupInfo } as never);
const result = await addBackup();
- call(mockedEnableExistingBackup).toMatchObject([chosenPath, mockDevice]);
- expect(result).toStrictEqual(mockBackupInfo);
+ call(mockedEnableExistingBackup).toMatchObject({
+ pathname: chosenPath,
+ device: mockDevice,
+ });
+ expect(result).toStrictEqual({ data: mockBackupInfo });
});
});
diff --git a/src/backend/features/backup/add-backup.ts b/src/backend/features/backup/add-backup.ts
new file mode 100644
index 0000000000..6db7e40ea2
--- /dev/null
+++ b/src/backend/features/backup/add-backup.ts
@@ -0,0 +1,43 @@
+import configStore from '../../../apps/main/config';
+import { createBackup } from './create-backup';
+import { DeviceModule } from '../../../backend/features/device/device.module';
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import { enableExistingBackup } from './enable-existing-backup';
+import { getPathFromDialog } from '../../../core/utils/get-path-from-dialog';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { Result } from '../../../context/shared/domain/Result';
+import { BackupInfo } from '../../../apps/backups/BackupInfo';
+
+export async function addBackup(): Promise> {
+ const { error, data } = await DeviceModule.getOrCreateDevice();
+ if (error) {
+ logger.error({ tag: 'BACKUPS', msg: 'Error fetching or creating device', error });
+ return { error: new Error('Error adding backup: No device found') };
+ }
+
+ const chosenItem = await getPathFromDialog();
+ if (!chosenItem) return { error: new Error('No path chosen') };
+
+ const chosenPath = createAbsolutePath(chosenItem.path);
+ const backupList = configStore.get('backupList');
+ const existingBackup = backupList[chosenPath];
+
+ if (!existingBackup) {
+ const { data: newBackup, error: createError } = await createBackup({ pathname: chosenPath, device: data });
+ if (createError) {
+ logger.error({ tag: 'BACKUPS', msg: 'Error creating backup', error: createError });
+ return { error: createError };
+ }
+ return { data: newBackup };
+ } else {
+ const { data: existingBackupInfo, error: enableError } = await enableExistingBackup({
+ pathname: chosenPath,
+ device: data,
+ });
+ if (enableError) {
+ logger.error({ tag: 'BACKUPS', msg: 'Error enabling existing backup', error: enableError });
+ return { error: enableError };
+ }
+ return { data: existingBackupInfo };
+ }
+}
diff --git a/src/backend/features/backup/build-backup-folder-tree-snapshot.test.ts b/src/backend/features/backup/build-backup-folder-tree-snapshot.test.ts
new file mode 100644
index 0000000000..6d85dfde59
--- /dev/null
+++ b/src/backend/features/backup/build-backup-folder-tree-snapshot.test.ts
@@ -0,0 +1,27 @@
+import { buildBackupFolderTreeSnapshot } from './build-backup-folder-tree-snapshot';
+
+describe('build-backup-folder-tree-snapshot', () => {
+ it('should accumulate all file sizes and decrypted names across tree', () => {
+ const decryptFileName = vi.fn((name: string) => `dec:${name}`);
+ const tree = {
+ id: 1,
+ plainName: 'root',
+ files: [{ id: 101, name: 'f1', folderId: 1, size: '2' }],
+ children: [
+ {
+ id: 2,
+ plainName: 'child',
+ files: [{ id: 102, name: 'f2', folderId: 2, size: '3' }],
+ children: [],
+ },
+ ],
+ };
+
+ const result = buildBackupFolderTreeSnapshot({ tree: tree as never, decryptFileName });
+
+ expect(result.size).toBe(5);
+ expect(result.folderDecryptedNames).toStrictEqual({ 1: 'root', 2: 'child' });
+ expect(result.fileDecryptedNames).toStrictEqual({ 101: 'dec:f1', 102: 'dec:f2' });
+ expect(decryptFileName).toBeCalledTimes(2);
+ });
+});
diff --git a/src/backend/features/backup/build-backup-folder-tree-snapshot.ts b/src/backend/features/backup/build-backup-folder-tree-snapshot.ts
new file mode 100644
index 0000000000..8c6ab7be2f
--- /dev/null
+++ b/src/backend/features/backup/build-backup-folder-tree-snapshot.ts
@@ -0,0 +1,52 @@
+import { FolderTree } from '@internxt/sdk/dist/drive/storage/types';
+import { BackupFolderTreeSnapshot } from './types/BackupFolderTreeSnapshot';
+
+type NodeSnapshot = {
+ folderId: number;
+ folderName: string;
+ fileNames: Record;
+ size: number;
+};
+
+type SnapshotProps = {
+ node: FolderTree;
+ decryptFileName: (name: string, folderId: number) => string;
+};
+
+function snapshotNode({ node, decryptFileName }: SnapshotProps): NodeSnapshot {
+ const fileNames: Record = {};
+ let size = 0;
+
+ for (const file of node.files) {
+ fileNames[file.id] = decryptFileName(file.name, file.folderId);
+ size += Number(file.size);
+ }
+
+ return { folderId: node.id, folderName: node.plainName, fileNames, size };
+}
+
+type Props = {
+ tree: FolderTree;
+ decryptFileName: (name: string, folderId: number) => string;
+};
+
+export function buildBackupFolderTreeSnapshot({ tree, decryptFileName }: Props): BackupFolderTreeSnapshot {
+ let size = 0;
+ const folderDecryptedNames: Record = {};
+ const fileDecryptedNames: Record = {};
+
+ const stack = [tree];
+
+ while (stack.length > 0) {
+ const currentNode = stack.pop()!;
+ const { folderId, folderName, fileNames, size: nodeSize } = snapshotNode({ node: currentNode, decryptFileName });
+
+ folderDecryptedNames[folderId] = folderName;
+ Object.assign(fileDecryptedNames, fileNames);
+ size += nodeSize;
+
+ stack.push(...currentNode.children);
+ }
+
+ return { tree, folderDecryptedNames, fileDecryptedNames, size };
+}
diff --git a/src/backend/features/backup/calculate-backups-items-count.test.ts b/src/backend/features/backup/calculate-backups-items-count.test.ts
index bb6c525d69..6978cc4d95 100644
--- a/src/backend/features/backup/calculate-backups-items-count.test.ts
+++ b/src/backend/features/backup/calculate-backups-items-count.test.ts
@@ -5,6 +5,7 @@ import * as precalculateBackupItemCountModule from './precalculate-backup-item-c
import { partialSpyOn } from 'tests/vitest/utils.helper';
import { loggerMock } from 'tests/vitest/mocks.helper';
import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { RemoteTreeBuilder } from '../../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder';
const makeBackup = (folderUuid: string): BackupInfo => ({
folderUuid,
@@ -17,11 +18,13 @@ const makeBackup = (folderUuid: string): BackupInfo => ({
describe('calculateBackupsItemsCount', () => {
let container: Container;
+ let remoteTreeBuilder: RemoteTreeBuilder;
let signal: AbortSignal;
const precalcuteBackupItemCountMock = partialSpyOn(precalculateBackupItemCountModule, 'precalculateBackupItemCount');
beforeEach(() => {
- container = { get: vi.fn().mockReturnValue({}) } as unknown as Container;
+ remoteTreeBuilder = {} as RemoteTreeBuilder;
+ container = { get: vi.fn().mockReturnValue(remoteTreeBuilder) } as unknown as Container;
signal = new AbortController().signal;
});
@@ -43,13 +46,14 @@ describe('calculateBackupsItemsCount', () => {
expect(result.size).toBe(2);
});
- it('calls precalculateBackupItemCount with the correct backup and container', async () => {
+ it('calls precalculateBackupItemCount with the correct backup and remote tree builder', async () => {
const backup = makeBackup('uuid-1');
precalcuteBackupItemCountMock.mockResolvedValueOnce({ data: 7 });
await calculateBackupsItemsCount({ backups: [backup], signal, container });
- expect(precalcuteBackupItemCountMock).toBeCalledWith(backup, expect.anything(), expect.anything());
+ expect(container.get).toBeCalledWith(RemoteTreeBuilder);
+ expect(precalcuteBackupItemCountMock).toBeCalledWith(backup, remoteTreeBuilder);
});
it('stops processing when signal is already aborted', async () => {
diff --git a/src/backend/features/backup/calculate-backups-items-count.ts b/src/backend/features/backup/calculate-backups-items-count.ts
index 37267e3d78..94dc67ce22 100644
--- a/src/backend/features/backup/calculate-backups-items-count.ts
+++ b/src/backend/features/backup/calculate-backups-items-count.ts
@@ -1,7 +1,6 @@
import { precalculateBackupItemCount } from './precalculate-backup-item-count';
import { logger } from '@internxt/drive-desktop-core/build/backend';
import { BackupInfo } from '../../../apps/backups/BackupInfo';
-import LocalTreeBuilder from '../../../context/local/localTree/application/LocalTreeBuilder';
import { RemoteTreeBuilder } from '../../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder';
import { Container } from 'diod';
@@ -13,7 +12,6 @@ type Props = {
export async function calculateBackupsItemsCount({ backups, signal, container }: Props) {
const itemCounts = new Map();
- const localTreeBuilder = container.get(LocalTreeBuilder);
const remoteTreeBuilder = container.get(RemoteTreeBuilder);
for (const backup of backups) {
@@ -22,7 +20,8 @@ export async function calculateBackupsItemsCount({ backups, signal, container }:
break;
}
- const result = await precalculateBackupItemCount(backup, localTreeBuilder, remoteTreeBuilder);
+ // eslint-disable-next-line no-await-in-loop
+ const result = await precalculateBackupItemCount(backup, remoteTreeBuilder);
if (result.error) {
logger.error({
tag: 'BACKUPS',
diff --git a/src/backend/features/backup/change-backup-path.test.ts b/src/backend/features/backup/change-backup-path.test.ts
new file mode 100644
index 0000000000..d0cccc8e97
--- /dev/null
+++ b/src/backend/features/backup/change-backup-path.test.ts
@@ -0,0 +1,112 @@
+import * as getBackupFolderUuidModule from '../../../infra/drive-server/services/folder/services/fetch-backup-folder-uuid';
+import * as renameFolderModule from '../../../infra/drive-server/services/folder/services/rename-folder';
+import * as migrateBackupEntryIfNeededModule from './migrate-backup-entry-if-needed';
+import configStoreModule from '../../../apps/main/config';
+import { DriveServerError } from '../../../infra/drive-server/drive-server.error';
+import { changeBackupPath } from './change-backup-path';
+import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+
+describe('change-backup-path', () => {
+ const mockedConfigStoreGet = partialSpyOn(configStoreModule, 'get');
+ const mockedConfigStoreSet = partialSpyOn(configStoreModule, 'set');
+ const mockedGetBackupFolderUuid = partialSpyOn(getBackupFolderUuidModule, 'getBackupFolderUuid');
+ const mockedRenameFolder = partialSpyOn(renameFolderModule, 'renameFolder');
+ const mockedMigrateBackupEntryIfNeeded = partialSpyOn(migrateBackupEntryIfNeededModule, 'migrateBackupEntryIfNeeded');
+
+ const currentPath = createAbsolutePath('/home/dev/Documents/current-backup');
+ const newPath = createAbsolutePath('/home/dev/Documents/new-backup');
+
+ it('should return error when backup no longer exists', async () => {
+ mockedConfigStoreGet.mockReturnValue({});
+
+ const result = await changeBackupPath({ currentPath, newPath });
+
+ expect(result).toMatchObject({ error: new Error('No backup found with the provided path') });
+ });
+
+ it('should return error when new path already exists as backup', async () => {
+ const existingBackup = { folderId: 12, folderUuid: 'folder-uuid', enabled: true };
+
+ mockedConfigStoreGet.mockReturnValue({
+ [currentPath]: existingBackup,
+ [newPath]: { folderId: 99, folderUuid: 'another-folder-uuid', enabled: true },
+ });
+
+ const result = await changeBackupPath({ currentPath, newPath });
+
+ expect(result).toMatchObject({ error: new Error('A backup with this path already exists') });
+ expect(mockedGetBackupFolderUuid).not.toBeCalled();
+ expect(mockedRenameFolder).not.toBeCalled();
+ expect(mockedConfigStoreSet).not.toBeCalled();
+ });
+
+ it('should return false when folder names are equal', async () => {
+ const currentPathWithSameName = createAbsolutePath('/home/dev/Documents/project');
+ const newPathWithSameName = createAbsolutePath('/mnt/external/project');
+
+ mockedConfigStoreGet.mockReturnValue({
+ [currentPathWithSameName]: { folderId: 12, folderUuid: 'folder-uuid', enabled: true },
+ });
+
+ const result = await changeBackupPath({ currentPath: currentPathWithSameName, newPath: newPathWithSameName });
+
+ expect(result).toStrictEqual({ data: false });
+ expect(mockedGetBackupFolderUuid).not.toBeCalled();
+ expect(mockedRenameFolder).not.toBeCalled();
+ expect(mockedConfigStoreSet).not.toBeCalled();
+ });
+
+ it('should rename backup folder and move backup entry to the new path', async () => {
+ const existingBackup = { folderId: 12, folderUuid: 'folder-uuid', enabled: true };
+ const migratedBackup = { folderId: 12, folderUuid: 'folder-uuid', enabled: true };
+ const backupList = {
+ [currentPath]: existingBackup,
+ };
+
+ mockedConfigStoreGet.mockReturnValue(backupList);
+ mockedGetBackupFolderUuid.mockResolvedValue({ data: 'remote-folder-uuid' });
+ mockedRenameFolder.mockResolvedValue({ data: {} });
+ mockedMigrateBackupEntryIfNeeded.mockResolvedValue(migratedBackup);
+
+ const result = await changeBackupPath({ currentPath, newPath });
+
+ expect(result).toStrictEqual({ data: true });
+ call(mockedGetBackupFolderUuid).toStrictEqual({ folderId: '12' });
+ call(mockedRenameFolder).toStrictEqual({
+ uuid: 'remote-folder-uuid',
+ plainName: 'new-backup',
+ });
+ call(mockedMigrateBackupEntryIfNeeded).toStrictEqual({ pathname: newPath, backup: existingBackup });
+ call(mockedConfigStoreSet).toStrictEqual([
+ 'backupList',
+ {
+ [newPath]: migratedBackup,
+ },
+ ]);
+ });
+
+ it('should return error when resolving remote backup folder uuid fails', async () => {
+ const existingBackup = { folderId: 12, folderUuid: 'folder-uuid', enabled: true };
+ const error = new DriveServerError('UNKNOWN', undefined, 'uuid lookup failed');
+
+ mockedConfigStoreGet.mockReturnValue({ [currentPath]: existingBackup });
+ mockedGetBackupFolderUuid.mockResolvedValue({ error });
+
+ const result = await changeBackupPath({ currentPath, newPath });
+
+ expect(result).toStrictEqual({ error });
+ });
+
+ it('should return error when rename request fails', async () => {
+ const existingBackup = { folderId: 12, folderUuid: 'folder-uuid', enabled: true };
+
+ mockedConfigStoreGet.mockReturnValue({ [currentPath]: existingBackup });
+ mockedGetBackupFolderUuid.mockResolvedValue({ data: 'remote-folder-uuid' });
+ mockedRenameFolder.mockResolvedValue({ error: new DriveServerError('UNKNOWN', undefined, 'rename failed') });
+
+ const result = await changeBackupPath({ currentPath, newPath });
+
+ expect(result).toMatchObject({ error: new Error('Error in the request to rename a backup') });
+ });
+});
diff --git a/src/backend/features/backup/change-backup-path.ts b/src/backend/features/backup/change-backup-path.ts
new file mode 100644
index 0000000000..9a2879c58d
--- /dev/null
+++ b/src/backend/features/backup/change-backup-path.ts
@@ -0,0 +1,57 @@
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import { basename } from 'node:path';
+import configStore from '../../../apps/main/config';
+import { getBackupFolderUuid } from '../../../infra/drive-server/services/folder/services/fetch-backup-folder-uuid';
+import { renameFolder } from '../../../infra/drive-server/services/folder/services/rename-folder';
+import { migrateBackupEntryIfNeeded } from './migrate-backup-entry-if-needed';
+import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { Result } from '../../../context/shared/domain/Result';
+
+type Props = {
+ currentPath: AbsolutePath;
+ newPath: AbsolutePath;
+};
+
+export async function changeBackupPath({ currentPath, newPath }: Props): Promise> {
+ const backupsList = configStore.get('backupList');
+ const existingBackup = backupsList[currentPath];
+
+ if (!existingBackup) {
+ return { error: new Error('No backup found with the provided path') };
+ }
+
+ if (backupsList[newPath]) {
+ return { error: new Error('A backup with this path already exists') };
+ }
+
+ const oldFolderName = basename(currentPath);
+ const newFolderName = basename(newPath);
+ if (oldFolderName !== newFolderName) {
+ logger.debug({ tag: 'BACKUPS', msg: 'Renaming backup', existingBackup });
+
+ const getFolderUuidResponse = await getBackupFolderUuid({ folderId: String(existingBackup.folderId) });
+ if (getFolderUuidResponse.error) {
+ return { error: getFolderUuidResponse.error };
+ }
+ const { data: folderUuid } = getFolderUuidResponse;
+
+ const res = await renameFolder({ uuid: folderUuid, plainName: newFolderName });
+ if (res.error) {
+ return { error: new Error('Error in the request to rename a backup') };
+ }
+
+ delete backupsList[currentPath];
+
+ const migratedExistingBackup = await migrateBackupEntryIfNeeded({
+ pathname: newPath,
+ backup: existingBackup,
+ });
+ backupsList[newPath] = migratedExistingBackup;
+
+ configStore.set('backupList', backupsList);
+
+ return { data: true };
+ }
+
+ return { data: false };
+}
diff --git a/src/apps/main/backups/create-backup-folder.test.ts b/src/backend/features/backup/create-backup-folder.test.ts
similarity index 97%
rename from src/apps/main/backups/create-backup-folder.test.ts
rename to src/backend/features/backup/create-backup-folder.test.ts
index fc02618bec..3c49ff8571 100644
--- a/src/apps/main/backups/create-backup-folder.test.ts
+++ b/src/backend/features/backup/create-backup-folder.test.ts
@@ -4,7 +4,7 @@ import { logger } from '@internxt/drive-desktop-core/build/backend';
import { DriveServerError } from '../../../infra/drive-server/drive-server.error';
import { call } from '../../../../tests/vitest/utils.helper';
import { partialSpyOn } from '../../../../tests/vitest/utils.helper';
-import * as findBackupFolderByNameModule from './find-backup-folder-by-name';
+import * as findBackupFolderByNameModule from '../../../apps/main/backups/find-backup-folder-by-name';
vi.mock(import('@internxt/drive-desktop-core/build/backend'));
diff --git a/src/apps/main/backups/create-backup-folder.ts b/src/backend/features/backup/create-backup-folder.ts
similarity index 84%
rename from src/apps/main/backups/create-backup-folder.ts
rename to src/backend/features/backup/create-backup-folder.ts
index d628777ba7..03f51aa5a8 100644
--- a/src/apps/main/backups/create-backup-folder.ts
+++ b/src/backend/features/backup/create-backup-folder.ts
@@ -1,8 +1,8 @@
-import { Device } from '../device/service';
-import { Backup } from './types';
+import { Device } from './types/Device';
+import { Backup } from '../../../apps/main/backups/types';
import { logger } from '@internxt/drive-desktop-core/build/backend';
import { createFolder } from '../../../infra/drive-server/services/folder/services/create-folder';
-import { findBackupFolderByName } from './find-backup-folder-by-name';
+import { findBackupFolderByName } from '../../../apps/main/backups/find-backup-folder-by-name';
type Props = {
folderName: string;
diff --git a/src/apps/main/backups/create-backup.test.ts b/src/backend/features/backup/create-backup.test.ts
similarity index 68%
rename from src/apps/main/backups/create-backup.test.ts
rename to src/backend/features/backup/create-backup.test.ts
index da6482d826..106917fb81 100644
--- a/src/apps/main/backups/create-backup.test.ts
+++ b/src/backend/features/backup/create-backup.test.ts
@@ -1,11 +1,13 @@
import { createBackup } from './create-backup';
-import { createBackupFolder } from './create-backup-folder';
-import configStore from '../config';
+import { createBackupFolder } from '../../../backend/features/backup/create-backup-folder';
+import configStore from '../../../apps/main/config';
+import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
import { app } from 'electron';
import path from 'node:path';
+import { DriveServerError } from 'src/infra/drive-server/drive-server.error';
vi.mock('./create-backup-folder');
-vi.mock('../config');
+vi.mock('../../../apps/main/config');
vi.mock('node:path');
const mockPostBackup = vi.mocked(createBackupFolder);
@@ -48,7 +50,7 @@ describe('createBackup', () => {
});
const result = await createBackup({
- pathname: '/home/user/TestFolder',
+ pathname: '/home/user/TestFolder' as AbsolutePath,
device: mockDevice,
});
@@ -66,26 +68,28 @@ describe('createBackup', () => {
});
expect(result).toStrictEqual({
- folderUuid: 'backup-uuid-456',
- folderId: 123,
- pathname: '/home/user/TestFolder',
- name: 'TestFolder',
- tmpPath: '/tmp',
- backupsBucket: 'test-bucket',
+ data: {
+ folderUuid: 'backup-uuid-456',
+ folderId: 123,
+ pathname: '/home/user/TestFolder',
+ name: 'TestFolder',
+ tmpPath: '/tmp',
+ backupsBucket: 'test-bucket',
+ },
});
});
it('should return undefined when createBackupFolder fails', async () => {
mockPostBackup.mockResolvedValue({
- error: new Error('Failed to create backup folder') as any,
+ error: new DriveServerError('NOT_FOUND'),
});
const result = await createBackup({
- pathname: '/home/user/FailedFolder',
+ pathname: '/home/user/FailedFolder' as AbsolutePath,
device: mockDevice,
});
- expect(result).toBeUndefined();
+ expect(result).toStrictEqual({ error: expect.any(Error) });
expect(mockConfigStore.set).not.toBeCalled();
});
});
diff --git a/src/apps/main/backups/create-backup.ts b/src/backend/features/backup/create-backup.ts
similarity index 50%
rename from src/apps/main/backups/create-backup.ts
rename to src/backend/features/backup/create-backup.ts
index df43b8240e..4645ffe57e 100644
--- a/src/apps/main/backups/create-backup.ts
+++ b/src/backend/features/backup/create-backup.ts
@@ -1,19 +1,21 @@
import path from 'node:path';
-import { Device } from '../device/service';
-import configStore from '../config';
-import { BackupInfo } from 'src/apps/backups/BackupInfo';
+import { Device } from './types/Device';
+import configStore from '../../../apps/main/config';
+import { BackupInfo } from '../../../apps/backups/BackupInfo';
import { app } from 'electron';
-import { createBackupFolder } from './create-backup-folder';
+import { createBackupFolder } from '../../../backend/features/backup/create-backup-folder';
+import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { Result } from '../../../context/shared/domain/Result';
type Props = {
- pathname: string;
+ pathname: AbsolutePath;
device: Device;
};
-export async function createBackup({ pathname, device }: Props) {
+export async function createBackup({ pathname, device }: Props): Promise> {
const { base } = path.parse(pathname);
const { error, data: newBackup } = await createBackupFolder({ folderName: base, device });
- if (error) return;
+ if (error) return { error };
const backupList = configStore.get('backupList');
backupList[pathname] = {
@@ -27,11 +29,11 @@ export async function createBackup({ pathname, device }: Props) {
const createdBackup: BackupInfo = {
folderUuid: newBackup.uuid,
folderId: newBackup.id,
- pathname: pathname,
+ pathname,
name: base,
tmpPath: app.getPath('temp'),
backupsBucket: device.bucket,
};
- return createdBackup;
+ return { data: createdBackup };
}
diff --git a/src/backend/features/backup/create-backups-from-local-paths.test.ts b/src/backend/features/backup/create-backups-from-local-paths.test.ts
new file mode 100644
index 0000000000..2048fe2376
--- /dev/null
+++ b/src/backend/features/backup/create-backups-from-local-paths.test.ts
@@ -0,0 +1,69 @@
+import * as createBackupModule from './create-backup';
+import * as DeviceModuleModule from '../device/device.module';
+import configStoreModule from '../../../apps/main/config';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { call, calls, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { createBackupsFromLocalPaths } from './create-backups-from-local-paths';
+
+describe('create-backups-from-local-paths', () => {
+ const createBackupMock = partialSpyOn(createBackupModule, 'createBackup');
+ const getOrCreateDeviceMock = partialSpyOn(DeviceModuleModule.DeviceModule, 'getOrCreateDevice');
+ const configStoreSetMock = partialSpyOn(configStoreModule, 'set');
+
+ it('should enable backups and create one backup per local path', async () => {
+ const device = {
+ id: 1,
+ uuid: 'device-uuid',
+ name: 'Device',
+ bucket: 'bucket',
+ removed: false,
+ hasBackups: true,
+ };
+
+ const folderPaths = [createAbsolutePath('/home/dev/Documents'), createAbsolutePath('/home/dev/Pictures')];
+
+ getOrCreateDeviceMock.mockResolvedValue({ data: device });
+ createBackupMock.mockResolvedValue(undefined as never);
+
+ const result = await createBackupsFromLocalPaths({ folderPaths });
+
+ expect(result).toStrictEqual({ data: true });
+ call(configStoreSetMock).toStrictEqual(['backupsEnabled', true]);
+ call(getOrCreateDeviceMock).toStrictEqual([]);
+ calls(createBackupMock).toStrictEqual([
+ { pathname: folderPaths[0], device },
+ { pathname: folderPaths[1], device },
+ ]);
+ });
+
+ it('should return an error when no device can be created or fetched', async () => {
+ const error = new Error('Device error');
+ const folderPaths = [createAbsolutePath('/home/dev/Documents')];
+
+ getOrCreateDeviceMock.mockResolvedValue({ error });
+
+ await expect(createBackupsFromLocalPaths({ folderPaths })).resolves.toStrictEqual({ error });
+ calls(createBackupMock).toHaveLength(0);
+ calls(configStoreSetMock).toHaveLength(0);
+ });
+
+ it('should return an error when creating a backup fails', async () => {
+ const error = new Error('Backup error');
+ const device = {
+ id: 1,
+ uuid: 'device-uuid',
+ name: 'Device',
+ bucket: 'bucket',
+ removed: false,
+ hasBackups: true,
+ };
+ const folderPaths = [createAbsolutePath('/home/dev/Documents')];
+
+ getOrCreateDeviceMock.mockResolvedValue({ data: device });
+ createBackupMock.mockRejectedValue(error);
+
+ await expect(createBackupsFromLocalPaths({ folderPaths })).rejects.toThrow('Backup error');
+ call(createBackupMock).toStrictEqual({ pathname: folderPaths[0], device });
+ calls(configStoreSetMock).toHaveLength(0);
+ });
+});
diff --git a/src/backend/features/backup/create-backups-from-local-paths.ts b/src/backend/features/backup/create-backups-from-local-paths.ts
new file mode 100644
index 0000000000..98fe1411ac
--- /dev/null
+++ b/src/backend/features/backup/create-backups-from-local-paths.ts
@@ -0,0 +1,22 @@
+import configStore from '../../../apps/main/config';
+import { createBackup } from './create-backup';
+import { DeviceModule } from '../device/device.module';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { Result } from '../../../context/shared/domain/Result';
+
+type Props = {
+ folderPaths: string[];
+};
+
+export async function createBackupsFromLocalPaths({ folderPaths }: Props): Promise> {
+ const { error, data } = await DeviceModule.getOrCreateDevice();
+ if (error) return { error };
+
+ const operations = folderPaths.map((folderPath) =>
+ createBackup({ pathname: createAbsolutePath(folderPath), device: data }),
+ );
+ await Promise.all(operations);
+
+ configStore.set('backupsEnabled', true);
+ return { data: true };
+}
diff --git a/src/backend/features/backup/delete-backup.test.ts b/src/backend/features/backup/delete-backup.test.ts
new file mode 100644
index 0000000000..ad592c6cec
--- /dev/null
+++ b/src/backend/features/backup/delete-backup.test.ts
@@ -0,0 +1,68 @@
+import * as addFolderToTrashModule from '../../../infra/drive-server/services/folder/services/add-folder-to-trash';
+import configStoreModule from '../../../apps/main/config';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { DriveServerError } from '../../../infra/drive-server/drive-server.error';
+import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { deleteBackup } from './delete-backup';
+
+describe('delete-backup', () => {
+ const addFolderToTrashMock = partialSpyOn(addFolderToTrashModule, 'addFolderToTrash');
+ const configStoreGetMock = partialSpyOn(configStoreModule, 'get');
+ const configStoreSetMock = partialSpyOn(configStoreModule, 'set');
+
+ const backup = {
+ folderUuid: 'folder-uuid',
+ folderId: 1,
+ tmpPath: '/tmp',
+ backupsBucket: 'bucket',
+ pathname: createAbsolutePath('/home/dev/Documents'),
+ name: 'Documents',
+ };
+
+ it('should return an error when request to trash folder fails', async () => {
+ addFolderToTrashMock.mockResolvedValue({ error: new DriveServerError('UNKNOWN', undefined, 'request failed') });
+
+ const result = await deleteBackup({ backup });
+
+ expect(result).toMatchObject({ error: { message: 'Request to delete backup wasnt succesful' } });
+ });
+
+ it('should not update backup list when isCurrent is false', async () => {
+ addFolderToTrashMock.mockResolvedValue({ data: undefined as never });
+
+ await deleteBackup({ backup, isCurrent: false });
+
+ call(addFolderToTrashMock).toBe('folder-uuid');
+ expect(configStoreGetMock).not.toBeCalled();
+ expect(configStoreSetMock).not.toBeCalled();
+ });
+
+ it('should remove backup from local list when isCurrent is true', async () => {
+ addFolderToTrashMock.mockResolvedValue({ data: undefined as never });
+ configStoreGetMock.mockReturnValue({
+ '/home/dev/Documents': backup,
+ '/home/dev/Pictures': {
+ ...backup,
+ folderId: 2,
+ folderUuid: 'folder-uuid-2',
+ pathname: createAbsolutePath('/home/dev/Pictures'),
+ name: 'Pictures',
+ },
+ } as never);
+
+ await deleteBackup({ backup, isCurrent: true });
+
+ call(configStoreSetMock).toStrictEqual([
+ 'backupList',
+ {
+ '/home/dev/Pictures': {
+ ...backup,
+ folderId: 2,
+ folderUuid: 'folder-uuid-2',
+ pathname: createAbsolutePath('/home/dev/Pictures'),
+ name: 'Pictures',
+ },
+ },
+ ]);
+ });
+});
diff --git a/src/backend/features/backup/delete-backup.ts b/src/backend/features/backup/delete-backup.ts
new file mode 100644
index 0000000000..ee3969930c
--- /dev/null
+++ b/src/backend/features/backup/delete-backup.ts
@@ -0,0 +1,26 @@
+import configStore from '../../../apps/main/config';
+import { BackupInfo } from '../../../apps/backups/BackupInfo';
+import { addFolderToTrash } from '../../../infra/drive-server/services/folder/services/add-folder-to-trash';
+import { Result } from '../../../context/shared/domain/Result';
+
+type Props = {
+ backup: BackupInfo;
+ isCurrent?: boolean;
+};
+
+export async function deleteBackup({ backup, isCurrent }: Props): Promise> {
+ const { error } = await addFolderToTrash(backup.folderUuid);
+ if (error) {
+ return { error: new Error('Request to delete backup wasnt succesful') };
+ }
+
+ if (isCurrent) {
+ const backupsList = configStore.get('backupList');
+ const entriesFiltered = Object.entries(backupsList).filter(([, b]) => b.folderId !== backup.folderId);
+ const backupListFiltered = Object.fromEntries(entriesFiltered);
+
+ configStore.set('backupList', backupListFiltered);
+ }
+
+ return { data: true };
+}
diff --git a/src/backend/features/backup/delete-device-backups.test.ts b/src/backend/features/backup/delete-device-backups.test.ts
new file mode 100644
index 0000000000..f036bceebb
--- /dev/null
+++ b/src/backend/features/backup/delete-device-backups.test.ts
@@ -0,0 +1,81 @@
+import * as addFolderToTrashModule from '../../../infra/drive-server/services/folder/services/add-folder-to-trash';
+import * as getBackupFolderTreeSnapshotModule from './get-backup-folder-tree-snapshot';
+import * as deleteBackupModule from './delete-backup';
+import * as DeviceModuleModule from '../device/device.module';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { calls, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { deleteDeviceBackups } from './delete-device-backups';
+
+describe('delete-device-backups', () => {
+ const getBackupsFromDeviceMock = partialSpyOn(DeviceModuleModule.DeviceModule, 'getBackupsFromDevice');
+ const deleteBackupMock = partialSpyOn(deleteBackupModule, 'deleteBackup');
+ const getBackupFolderTreeSnapshotMock = partialSpyOn(
+ getBackupFolderTreeSnapshotModule,
+ 'getBackupFolderTreeSnapshot',
+ );
+ const addFolderToTrashMock = partialSpyOn(addFolderToTrashModule, 'addFolderToTrash');
+
+ const device = {
+ id: 1,
+ uuid: 'device-uuid',
+ name: 'Desktop',
+ bucket: 'bucket',
+ removed: false,
+ hasBackups: true,
+ };
+
+ it('should delete each backup and trash only stale folders from backup tree', async () => {
+ const backups = [
+ {
+ folderUuid: 'folder-uuid-1',
+ folderId: 10,
+ tmpPath: '/tmp',
+ backupsBucket: 'bucket',
+ pathname: createAbsolutePath('/home/dev/Documents'),
+ name: 'Documents',
+ },
+ ];
+
+ getBackupsFromDeviceMock.mockResolvedValue(backups);
+ deleteBackupMock.mockResolvedValue(undefined);
+ getBackupFolderTreeSnapshotMock.mockResolvedValue({
+ data: {
+ tree: {
+ children: [
+ { id: 10, uuid: 'folder-uuid-1' },
+ { id: 20, uuid: 'folder-uuid-2' },
+ ],
+ },
+ },
+ } as never);
+ addFolderToTrashMock.mockResolvedValue({ data: undefined as never });
+
+ await deleteDeviceBackups({ device, isCurrent: true });
+
+ calls(deleteBackupMock).toStrictEqual([{ backup: backups[0], isCurrent: true }]);
+ calls(addFolderToTrashMock).toStrictEqual(['folder-uuid-2']);
+ });
+
+ it('should not trash any folder when all tree children belong to backups', async () => {
+ const backups = [
+ {
+ folderUuid: 'folder-uuid-1',
+ folderId: 10,
+ tmpPath: '/tmp',
+ backupsBucket: 'bucket',
+ pathname: createAbsolutePath('/home/dev/Documents'),
+ name: 'Documents',
+ },
+ ];
+
+ getBackupsFromDeviceMock.mockResolvedValue(backups);
+ deleteBackupMock.mockResolvedValue(undefined);
+ getBackupFolderTreeSnapshotMock.mockResolvedValue({
+ data: { tree: { children: [{ id: 10, uuid: 'folder-uuid-1' }] } },
+ } as never);
+
+ await deleteDeviceBackups({ device, isCurrent: false });
+
+ expect(addFolderToTrashMock).not.toBeCalled();
+ });
+});
diff --git a/src/backend/features/backup/delete-device-backups.ts b/src/backend/features/backup/delete-device-backups.ts
new file mode 100644
index 0000000000..a821caf833
--- /dev/null
+++ b/src/backend/features/backup/delete-device-backups.ts
@@ -0,0 +1,33 @@
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import type { Device } from './types/Device';
+import { DeviceModule } from '../device/device.module';
+import { addFolderToTrash } from '../../../infra/drive-server/services/folder/services/add-folder-to-trash';
+import { getBackupFolderTreeSnapshot } from './get-backup-folder-tree-snapshot';
+import { deleteBackup } from './delete-backup';
+
+type Props = {
+ device: Device;
+ isCurrent?: boolean;
+};
+
+export async function deleteDeviceBackups({ device, isCurrent }: Props) {
+ const backups = await DeviceModule.getBackupsFromDevice(device, isCurrent);
+ logger.debug({ tag: 'BACKUPS', msg: '[BACKUPS] Deleting backups from device', count: backups.length });
+ logger.debug({ tag: 'BACKUPS', msg: '[BACKUPS] Backups details', backups });
+
+ const backupDeletionPromises = backups.map((backup) => deleteBackup({ backup, isCurrent }));
+ await Promise.all(backupDeletionPromises);
+
+ const { error, data } = await getBackupFolderTreeSnapshot({ folderUuid: device.uuid });
+ if (error) {
+ logger.error({ tag: 'BACKUPS', msg: 'Error fetching backup folder tree snapshot', error });
+ return;
+ }
+
+ const { tree } = data;
+ const foldersToDelete = tree.children.filter((folder) => !backups.some((backup) => backup.folderId === folder.id));
+ const folderDeletionPromises = foldersToDelete.map(async (folder) => {
+ await addFolderToTrash(folder.uuid);
+ });
+ await Promise.all(folderDeletionPromises);
+}
diff --git a/src/backend/features/backup/disable-backup.test.ts b/src/backend/features/backup/disable-backup.test.ts
new file mode 100644
index 0000000000..b48b9e6d47
--- /dev/null
+++ b/src/backend/features/backup/disable-backup.test.ts
@@ -0,0 +1,75 @@
+import * as findBackupPathnameFromIdModule from './find-backup-pathname-from-id';
+import * as getBackupFolderTreeSnapshotModule from './get-backup-folder-tree-snapshot';
+import * as deleteBackupModule from './delete-backup';
+import configStoreModule from '../../../apps/main/config';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { loggerMock } from '../../../../tests/vitest/mocks.helper';
+import { disableBackup } from './disable-backup';
+
+describe('disable-backup', () => {
+ const findBackupPathnameFromIdMock = partialSpyOn(findBackupPathnameFromIdModule, 'findBackupPathnameFromId');
+ const getBackupFolderTreeSnapshotMock = partialSpyOn(
+ getBackupFolderTreeSnapshotModule,
+ 'getBackupFolderTreeSnapshot',
+ );
+ const deleteBackupMock = partialSpyOn(deleteBackupModule, 'deleteBackup');
+ const configStoreGetMock = partialSpyOn(configStoreModule, 'get');
+ const configStoreSetMock = partialSpyOn(configStoreModule, 'set');
+
+ const backup = {
+ folderUuid: 'folder-uuid',
+ folderId: 1,
+ tmpPath: '/tmp',
+ backupsBucket: 'bucket',
+ pathname: createAbsolutePath('/home/dev/Documents'),
+ name: 'Documents',
+ };
+
+ it('should throw when backup pathname is not found', async () => {
+ configStoreGetMock.mockReturnValue({});
+ findBackupPathnameFromIdMock.mockReturnValue(undefined);
+
+ await expect(disableBackup({ backup })).rejects.toBeUndefined();
+
+ expect(configStoreSetMock).not.toBeCalled();
+ expect(getBackupFolderTreeSnapshotMock).not.toBeCalled();
+ });
+
+ it('should disable backup and delete it when tree size is zero', async () => {
+ const backupList = {
+ '/home/dev/Documents': { folderId: 1, folderUuid: 'folder-uuid', enabled: true },
+ };
+
+ configStoreGetMock.mockReturnValue(backupList);
+ findBackupPathnameFromIdMock.mockReturnValue('/home/dev/Documents');
+ getBackupFolderTreeSnapshotMock.mockResolvedValue({ data: { size: 0 } } as never);
+ deleteBackupMock.mockResolvedValue({ data: true });
+
+ await disableBackup({ backup });
+
+ call(configStoreSetMock).toStrictEqual([
+ 'backupList',
+ {
+ '/home/dev/Documents': { folderId: 1, folderUuid: 'folder-uuid', enabled: false },
+ },
+ ]);
+ call(deleteBackupMock).toStrictEqual({ backup, isCurrent: true });
+ });
+
+ it('should log error when fetching the backup folder tree snapshot fails', async () => {
+ const error = new Error('snapshot failed');
+ configStoreGetMock.mockReturnValue({
+ '/home/dev/Documents': { folderId: 1, folderUuid: 'folder-uuid', enabled: true },
+ });
+ findBackupPathnameFromIdMock.mockReturnValue('/home/dev/Documents');
+ getBackupFolderTreeSnapshotMock.mockResolvedValue({ error } as never);
+
+ await expect(disableBackup({ backup })).rejects.toBeUndefined();
+
+ call(loggerMock.error).toMatchObject({
+ tag: 'BACKUPS',
+ msg: 'Error fetching backup folder tree snapshot',
+ });
+ });
+});
diff --git a/src/backend/features/backup/disable-backup.ts b/src/backend/features/backup/disable-backup.ts
new file mode 100644
index 0000000000..318fc533fd
--- /dev/null
+++ b/src/backend/features/backup/disable-backup.ts
@@ -0,0 +1,35 @@
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import configStore from '../../../apps/main/config';
+import { BackupInfo } from '../../../apps/backups/BackupInfo';
+import { findBackupPathnameFromId } from './find-backup-pathname-from-id';
+import { getBackupFolderTreeSnapshot } from './get-backup-folder-tree-snapshot';
+import { deleteBackup } from './delete-backup';
+
+type Props = {
+ backup: BackupInfo;
+};
+
+export async function disableBackup({ backup }: Props): Promise {
+ const backupsList = configStore.get('backupList');
+ const pathname = findBackupPathnameFromId({ id: backup.folderId });
+
+ if (!pathname) {
+ throw logger.error({ tag: 'BACKUPS', msg: 'Error finding backup pathname to disable backup' });
+ }
+
+ backupsList[pathname].enabled = false;
+ configStore.set('backupList', backupsList);
+
+ const { error, data } = await getBackupFolderTreeSnapshot({ folderUuid: backup.folderUuid });
+ if (error) {
+ throw logger.error({ tag: 'BACKUPS', msg: 'Error fetching backup folder tree snapshot', error });
+ }
+
+ const { size } = data;
+ if (size === 0) {
+ const { error } = await deleteBackup({ backup, isCurrent: true });
+ if (error) {
+ throw logger.error({ tag: 'BACKUPS', msg: 'Error deleting backup after disabling it', error });
+ }
+ }
+}
diff --git a/src/backend/features/backup/download-backup.test.ts b/src/backend/features/backup/download-backup.test.ts
new file mode 100644
index 0000000000..0c4f5d6efa
--- /dev/null
+++ b/src/backend/features/backup/download-backup.test.ts
@@ -0,0 +1,122 @@
+import path from 'node:path';
+import { rm } from 'node:fs/promises';
+import { ipcMain } from 'electron';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { loggerMock } from '../../../../tests/vitest/mocks.helper';
+import * as windowsModule from '../../../apps/main/windows';
+import * as downloadDeviceBackupZipModule from './download-device-backup-zip';
+import * as authServiceModule from '../../../apps/main/auth/service';
+import { downloadBackup } from './download-backup';
+
+vi.mock('node:fs/promises', () => ({
+ rm: vi.fn(),
+}));
+
+describe('download-backup', () => {
+ const broadcastToWindowsMock = partialSpyOn(windowsModule, 'broadcastToWindows');
+ const downloadDeviceBackupZipMock = partialSpyOn(downloadDeviceBackupZipModule, 'downloadDeviceBackupZip');
+ const getUserMock = partialSpyOn(authServiceModule, 'getUser');
+
+ const ipcMainOnMock = vi.mocked(ipcMain.on);
+ const rmMock = vi.mocked(rm);
+
+ const user = { bridgeUser: 'bridge-user', userId: 'user-id' };
+
+ const device = {
+ id: 1,
+ uuid: 'device-uuid',
+ name: 'Desktop',
+ bucket: 'bucket',
+ removed: false,
+ hasBackups: true,
+ };
+
+ const pathname = createAbsolutePath('/home/dev/Downloads');
+
+ let removeListenerMock: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2026, 3, 21, 9, 8, 7));
+
+ removeListenerMock = vi.fn();
+ ipcMainOnMock.mockReturnValue({ removeListener: removeListenerMock } as never);
+ rmMock.mockResolvedValue(undefined as never);
+ getUserMock.mockReturnValue(user as never);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should download backup and broadcast progress when not aborted', async () => {
+ downloadDeviceBackupZipMock.mockImplementation(async ({ updateProgress }) => {
+ updateProgress(33);
+ });
+
+ await downloadBackup({ device, pathname });
+
+ call(loggerMock.debug).toMatchObject({
+ tag: 'BACKUPS',
+ msg: '[BACKUPS] Downloading Device',
+ deviceName: device.name,
+ pathname,
+ });
+
+ call(downloadDeviceBackupZipMock).toMatchObject({
+ device,
+ path: path.join(pathname, 'Backup_2026421987.zip'),
+ });
+
+ call(broadcastToWindowsMock).toStrictEqual([
+ 'backup-download-progress',
+ {
+ id: device.uuid,
+ progress: 33,
+ },
+ ]);
+
+ expect(rmMock).not.toHaveBeenCalled();
+ expect(removeListenerMock).toHaveBeenCalledWith('abort-download-backups-' + device.uuid, expect.any(Function));
+ });
+
+ it('should skip broadcasting progress when aborted for the same device', async () => {
+ downloadDeviceBackupZipMock.mockImplementation(async ({ updateProgress }) => {
+ const abortListener = ipcMainOnMock.mock.calls[0]?.[1];
+ abortListener?.({} as never, device.uuid);
+ updateProgress(90);
+ });
+
+ await downloadBackup({ device, pathname });
+
+ expect(broadcastToWindowsMock).not.toHaveBeenCalled();
+ });
+
+ it('should keep broadcasting when abort event is for another device', async () => {
+ downloadDeviceBackupZipMock.mockImplementation(async ({ updateProgress }) => {
+ const abortListener = ipcMainOnMock.mock.calls[0]?.[1];
+ abortListener?.({} as never, 'other-device-uuid');
+ updateProgress(12);
+ });
+
+ await downloadBackup({ device, pathname });
+
+ call(broadcastToWindowsMock).toStrictEqual([
+ 'backup-download-progress',
+ {
+ id: device.uuid,
+ progress: 12,
+ },
+ ]);
+ });
+
+ it('should remove generated zip file when download fails', async () => {
+ downloadDeviceBackupZipMock.mockRejectedValue(new Error('download failed'));
+
+ await downloadBackup({ device, pathname });
+
+ call(rmMock).toStrictEqual([path.join(pathname, 'Backup_2026421987.zip'), { force: true }]);
+ expect(removeListenerMock).toHaveBeenCalledWith('abort-download-backups-' + device.uuid, expect.any(Function));
+ });
+});
diff --git a/src/backend/features/backup/download-backup.ts b/src/backend/features/backup/download-backup.ts
new file mode 100644
index 0000000000..8eef91919c
--- /dev/null
+++ b/src/backend/features/backup/download-backup.ts
@@ -0,0 +1,78 @@
+import { rm } from 'node:fs/promises';
+import { IpcMainEvent, ipcMain } from 'electron';
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import type { Device } from './types/Device';
+import { broadcastToWindows } from '../../../apps/main/windows';
+import { downloadDeviceBackupZip } from './download-device-backup-zip';
+import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import path from 'node:path';
+import { getUser } from '../../../apps/main/auth/service';
+
+function createBackupZipFilePath({ pathname }: { pathname: AbsolutePath }) {
+ const date = new Date();
+ const timestamp = [
+ String(date.getFullYear()),
+ String(date.getMonth() + 1),
+ String(date.getDate()),
+ String(date.getHours()),
+ String(date.getMinutes()),
+ String(date.getSeconds()),
+ ].join('');
+
+ return path.join(pathname, `Backup_${timestamp}.zip`);
+}
+
+type Props = {
+ device: Device;
+ pathname: AbsolutePath;
+};
+
+export async function downloadBackup({ device, pathname }: Props): Promise {
+ const user = getUser();
+ if (!user) {
+ throw logger.error({ tag: 'BACKUPS', msg: 'No user found when trying to download backup' });
+ }
+
+ logger.debug({
+ tag: 'BACKUPS',
+ msg: '[BACKUPS] Downloading Device',
+ deviceName: device.name,
+ pathname,
+ });
+
+ const zipFilePath = createBackupZipFilePath({ pathname });
+ const abortController = new AbortController();
+
+ const abortListener = (_: IpcMainEvent, abortDeviceUuid: string) => {
+ if (abortDeviceUuid === device.uuid) {
+ abortController.abort();
+ }
+ };
+
+ const listenerName = 'abort-download-backups-' + device.uuid;
+ const removeListenerIpc = ipcMain.on(listenerName, abortListener);
+
+ try {
+ await downloadDeviceBackupZip({
+ user,
+ device,
+ path: zipFilePath,
+ updateProgress: (progress) => {
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ broadcastToWindows('backup-download-progress', {
+ id: device.uuid,
+ progress,
+ });
+ },
+ abortController,
+ });
+ } catch (error) {
+ logger.error({ tag: 'BACKUPS', msg: 'Error downloading backup for device', deviceName: device.name, error });
+ await rm(zipFilePath, { force: true });
+ }
+
+ removeListenerIpc.removeListener(listenerName, abortListener);
+}
diff --git a/src/backend/features/backup/download-device-backup-zip.test.ts b/src/backend/features/backup/download-device-backup-zip.test.ts
new file mode 100644
index 0000000000..1943860ef2
--- /dev/null
+++ b/src/backend/features/backup/download-device-backup-zip.test.ts
@@ -0,0 +1,58 @@
+import * as fetchFolderModule from '../../../infra/drive-server/services/folder/services/fetch-folder';
+import * as getCredentialsModule from '../../../apps/main/auth/get-credentials';
+import * as downloadModule from '../../../apps/main/network/download';
+import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { downloadDeviceBackupZip } from './download-device-backup-zip';
+import { User } from '../../../apps/main/types';
+
+describe('download-device-backup-zip', () => {
+ const fetchFolderMock = partialSpyOn(fetchFolderModule, 'fetchFolder');
+ const getCredentialsMock = partialSpyOn(getCredentialsModule, 'getCredentials');
+ const downloadFolderAsZipMock = partialSpyOn(downloadModule, 'downloadFolderAsZip');
+
+ const updateProgress = vi.fn();
+ const abortController = new AbortController();
+ const user = { bridgeUser: 'bridge-user', userId: 'user-id' } as unknown as User;
+
+ const device = {
+ id: 1,
+ uuid: 'device-uuid',
+ name: 'Laptop',
+ bucket: 'bucket',
+ removed: false,
+ hasBackups: true,
+ };
+
+ it('should return error when folder fetch fails', async () => {
+ fetchFolderMock.mockResolvedValue({ error: new Error('fetch failed') } as never);
+
+ const result = await downloadDeviceBackupZip({ user, device, path: '/tmp/backup.zip', updateProgress });
+
+ expect(result.error?.message).toBe('Unsuccesful request to fetch folder');
+ });
+
+ it('should download backup zip with credentials and progress hooks', async () => {
+ process.env.BRIDGE_URL = 'https://bridge.local';
+ fetchFolderMock.mockResolvedValue({ data: { uuid: 'folder-uuid' } } as never);
+ getCredentialsMock.mockReturnValue({ mnemonic: 'mnemonic' } as never);
+ downloadFolderAsZipMock.mockResolvedValue(undefined as never);
+
+ await downloadDeviceBackupZip({ user, device, path: '/tmp/backup.zip', updateProgress, abortController });
+
+ call(downloadFolderAsZipMock).toStrictEqual([
+ 'Laptop',
+ 'https://bridge.local',
+ 'folder-uuid',
+ '/tmp/backup.zip',
+ {
+ bridgeUser: 'bridge-user',
+ bridgePass: 'user-id',
+ encryptionKey: 'mnemonic',
+ },
+ {
+ abortController,
+ updateProgress,
+ },
+ ]);
+ });
+});
diff --git a/src/backend/features/backup/download-device-backup-zip.ts b/src/backend/features/backup/download-device-backup-zip.ts
new file mode 100644
index 0000000000..9dd0affa1a
--- /dev/null
+++ b/src/backend/features/backup/download-device-backup-zip.ts
@@ -0,0 +1,58 @@
+import { PathLike } from 'node:fs';
+import type { Device } from './types/Device';
+import { User } from '../../../apps/main/types';
+import { fetchFolder } from '../../../infra/drive-server/services/folder/services/fetch-folder';
+import { getCredentials } from '../../../apps/main/auth/get-credentials';
+import { downloadFolderAsZip } from '../../../apps/main/network/download';
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import { Result } from '../../../context/shared/domain/Result';
+
+type Props = {
+ user: User;
+ device: Device;
+ path: PathLike;
+ updateProgress: (progress: number) => void;
+ abortController?: AbortController;
+};
+
+export async function downloadDeviceBackupZip({
+ user,
+ device,
+ path,
+ updateProgress,
+ abortController,
+}: Props): Promise> {
+ const { data: folder, error } = await fetchFolder(device.uuid);
+ if (error) {
+ logger.error({ tag: 'BACKUPS', msg: 'Unsuccesful request to fetch folder', error });
+ return { error: new Error('Unsuccesful request to fetch folder') };
+ }
+
+ if (!folder || folder.uuid.length === 0) {
+ logger.error({ tag: 'BACKUPS', msg: 'No backup data found' });
+ return { error: new Error('No backup data found') };
+ }
+
+ const networkApiUrl = process.env.BRIDGE_URL;
+ const bridgeUser = user.bridgeUser;
+ const bridgePass = user.userId;
+ const { mnemonic } = getCredentials();
+
+ await downloadFolderAsZip(
+ device.name,
+ networkApiUrl,
+ folder.uuid,
+ path,
+ {
+ bridgeUser,
+ bridgePass,
+ encryptionKey: mnemonic,
+ },
+ {
+ abortController,
+ updateProgress,
+ },
+ );
+
+ return { data: true };
+}
diff --git a/src/backend/features/backup/enable-existing-backup.test.ts b/src/backend/features/backup/enable-existing-backup.test.ts
new file mode 100644
index 0000000000..ef1942df3b
--- /dev/null
+++ b/src/backend/features/backup/enable-existing-backup.test.ts
@@ -0,0 +1,92 @@
+import { enableExistingBackup } from './enable-existing-backup';
+import configStore from '../../../apps/main/config';
+import { fetchFolder } from '../../../infra/drive-server/services/folder/services/fetch-folder';
+import { createBackup } from './create-backup';
+import { migrateBackupEntryIfNeeded } from './migrate-backup-entry-if-needed';
+import { PATHS } from '../../../core/electron/paths';
+import { createAbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { DriveServerError } from 'src/infra/drive-server/drive-server.error';
+import { GetFolderContentDto } from 'src/infra/drive-server/out/dto';
+
+vi.mock('../../../apps/main/config');
+vi.mock('../../../infra/drive-server/services/folder/services/fetch-folder');
+vi.mock('./create-backup');
+vi.mock('../../../backend/features/backup/migrate-backup-entry-if-needed');
+
+const mockedConfigStore = vi.mocked(configStore);
+const mockedFetchFolder = vi.mocked(fetchFolder);
+const mockedCreateBackup = vi.mocked(createBackup);
+const mockedMigrateBackupEntryIfNeeded = vi.mocked(migrateBackupEntryIfNeeded);
+
+describe('enable-existing-backup', () => {
+ const mockDevice = {
+ id: 123,
+ bucket: 'test-bucket',
+ uuid: 'device-uuid',
+ name: 'Test Device',
+ removed: false,
+ hasBackups: false,
+ };
+
+ const pathname = createAbsolutePath('/path/to/backup');
+ const existingBackupData = {
+ folderUuid: 'existing-uuid',
+ folderId: 456,
+ enabled: false,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should create new backup when folder no longer exists', async () => {
+ const mockNewBackupInfo = {
+ folderUuid: 'new-folder-uuid',
+ folderId: 789,
+ pathname,
+ name: 'backup',
+ tmpPath: '/tmp',
+ backupsBucket: 'test-bucket',
+ };
+
+ mockedConfigStore.get.mockReturnValue({ [pathname]: existingBackupData });
+ mockedFetchFolder.mockResolvedValue({ error: new DriveServerError('NOT_FOUND', 400, 'Folder not found') });
+ mockedCreateBackup.mockResolvedValue({ data: mockNewBackupInfo });
+
+ const result = await enableExistingBackup({ pathname, device: mockDevice });
+
+ expect(mockedMigrateBackupEntryIfNeeded).not.toBeCalled();
+ expect(mockedFetchFolder).toBeCalledWith(existingBackupData.folderUuid);
+ expect(mockedCreateBackup).toBeCalledWith({ pathname, device: mockDevice });
+ expect(result).toStrictEqual({ data: mockNewBackupInfo });
+ });
+
+ it('should enable existing backup when folder still exists', async () => {
+ mockedConfigStore.get
+ .mockReturnValueOnce({ [pathname]: existingBackupData })
+ .mockReturnValueOnce({ [pathname]: existingBackupData });
+
+ mockedFetchFolder.mockResolvedValue({
+ data: { id: existingBackupData.folderId } as unknown as GetFolderContentDto,
+ });
+
+ const result = await enableExistingBackup({ pathname, device: mockDevice });
+
+ expect(mockedMigrateBackupEntryIfNeeded).not.toBeCalled();
+ expect(mockedFetchFolder).toBeCalledWith(existingBackupData.folderUuid);
+ expect(mockedConfigStore.set).toBeCalledWith('backupList', {
+ [pathname]: { ...existingBackupData, enabled: true },
+ });
+
+ expect(result).toStrictEqual({
+ data: {
+ folderUuid: existingBackupData.folderUuid,
+ folderId: existingBackupData.folderId,
+ pathname,
+ name: 'backup',
+ tmpPath: PATHS.TEMPORAL_FOLDER,
+ backupsBucket: mockDevice.bucket,
+ },
+ });
+ });
+});
diff --git a/src/backend/features/backup/enable-existing-backup.ts b/src/backend/features/backup/enable-existing-backup.ts
new file mode 100644
index 0000000000..84dcacb2f8
--- /dev/null
+++ b/src/backend/features/backup/enable-existing-backup.ts
@@ -0,0 +1,76 @@
+import configStore from '../../../apps/main/config';
+import { BackupInfo } from '../../../apps/backups/BackupInfo';
+import { parse } from 'node:path';
+import { fetchFolder } from '../../../infra/drive-server/services/folder/services/fetch-folder';
+import { createBackup } from './create-backup';
+import { migrateBackupEntryIfNeeded } from './migrate-backup-entry-if-needed';
+import { Device } from './types/Device';
+import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import { PATHS } from '../../../core/electron/paths';
+import { Result } from '../../../context/shared/domain/Result';
+import { BackupEntry } from './types/BackupEntry';
+
+type Props = {
+ pathname: AbsolutePath;
+ device: Device;
+};
+
+async function resolveBackupEntry({
+ pathname,
+ backup,
+}: {
+ pathname: AbsolutePath;
+ backup: BackupEntry;
+}): Promise> {
+ if (backup.folderUuid) {
+ return { data: backup };
+ }
+
+ return migrateBackupEntryIfNeeded({ pathname, backup });
+}
+
+function markBackupAsEnabled({ pathname }: { pathname: AbsolutePath }) {
+ const backupList = configStore.get('backupList');
+ configStore.set('backupList', { ...backupList, [pathname]: { ...backupList[pathname], enabled: true } });
+}
+
+function buildBackupInfo({
+ pathname,
+ backup,
+ device,
+}: {
+ pathname: AbsolutePath;
+ backup: BackupEntry;
+ device: Device;
+}): BackupInfo {
+ const { base } = parse(pathname);
+ return {
+ folderUuid: backup.folderUuid,
+ folderId: backup.folderId,
+ pathname,
+ name: base,
+ tmpPath: PATHS.TEMPORAL_FOLDER,
+ backupsBucket: device.bucket,
+ };
+}
+
+export async function enableExistingBackup({ pathname, device }: Props): Promise> {
+ const backupList = configStore.get('backupList');
+ const rawBackup = backupList[pathname];
+
+ const { data: backup, error } = await resolveBackupEntry({ pathname, backup: rawBackup });
+ if (error) return { error };
+
+ const { error: fetchError } = await fetchFolder(backup.folderUuid);
+ if (fetchError) {
+ const { data, error } = await createBackup({ pathname, device });
+ if (error) return { error };
+
+ return { data };
+ }
+
+ markBackupAsEnabled({ pathname });
+ const backupInfo = buildBackupInfo({ pathname, backup, device });
+
+ return { data: backupInfo };
+}
diff --git a/src/backend/features/backup/find-backup-pathname-from-id.test.ts b/src/backend/features/backup/find-backup-pathname-from-id.test.ts
new file mode 100644
index 0000000000..744a20f1bf
--- /dev/null
+++ b/src/backend/features/backup/find-backup-pathname-from-id.test.ts
@@ -0,0 +1,28 @@
+import configStoreModule from '../../../apps/main/config';
+import { partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { findBackupPathnameFromId } from './find-backup-pathname-from-id';
+
+describe('find-backup-pathname-from-id', () => {
+ const configStoreGetMock = partialSpyOn(configStoreModule, 'get');
+
+ it('should return pathname when backup id exists', () => {
+ configStoreGetMock.mockReturnValue({
+ '/home/dev/Documents': { folderId: 1, enabled: true, folderUuid: 'uuid-1' },
+ '/home/dev/Pictures': { folderId: 2, enabled: true, folderUuid: 'uuid-2' },
+ });
+
+ const result = findBackupPathnameFromId({ id: 2 });
+
+ expect(result).toBe('/home/dev/Pictures');
+ });
+
+ it('should return undefined when backup id does not exist', () => {
+ configStoreGetMock.mockReturnValue({
+ '/home/dev/Documents': { folderId: 1, enabled: true, folderUuid: 'uuid-1' },
+ });
+
+ const result = findBackupPathnameFromId({ id: 99 });
+
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/src/backend/features/backup/find-backup-pathname-from-id.ts b/src/backend/features/backup/find-backup-pathname-from-id.ts
new file mode 100644
index 0000000000..d2366b7ece
--- /dev/null
+++ b/src/backend/features/backup/find-backup-pathname-from-id.ts
@@ -0,0 +1,12 @@
+import configStore from '../../../apps/main/config';
+
+type Props = {
+ id: number;
+};
+
+export function findBackupPathnameFromId({ id }: Props): string | undefined {
+ const backupsList = configStore.get('backupList');
+ const entryfound = Object.entries(backupsList).find(([, backup]) => backup.folderId === id);
+
+ return entryfound?.[0];
+}
diff --git a/src/backend/features/backup/get-backup-folder-tree-snapshot.test.ts b/src/backend/features/backup/get-backup-folder-tree-snapshot.test.ts
new file mode 100644
index 0000000000..0ed04d8663
--- /dev/null
+++ b/src/backend/features/backup/get-backup-folder-tree-snapshot.test.ts
@@ -0,0 +1,40 @@
+import { aes } from '@internxt/lib';
+import * as fetchFolderTreeByUuidModule from '../../../infra/drive-server/services/folder/services/fetch-folder-tree-by-uuid';
+import * as buildBackupFolderTreeSnapshotModule from './build-backup-folder-tree-snapshot';
+import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { getBackupFolderTreeSnapshot } from './get-backup-folder-tree-snapshot';
+
+describe('get-backup-folder-tree-snapshot', () => {
+ const fetchFolderTreeByUuidMock = partialSpyOn(fetchFolderTreeByUuidModule, 'fetchFolderTreeByUuid');
+ const buildBackupFolderTreeSnapshotMock = partialSpyOn(
+ buildBackupFolderTreeSnapshotModule,
+ 'buildBackupFolderTreeSnapshot',
+ );
+ const aesDecryptMock = partialSpyOn(aes, 'decrypt');
+
+ it('should return an error when fetching folder tree fails', async () => {
+ const error = new Error('Unsuccesful request to fetch folder tree');
+ fetchFolderTreeByUuidMock.mockResolvedValue({ error: new Error('fetch failed') } as never);
+
+ await expect(getBackupFolderTreeSnapshot({ folderUuid: 'folder-uuid' })).resolves.toStrictEqual({ error });
+ });
+
+ it('should build backup tree snapshot and provide decrypt function', async () => {
+ process.env.NEW_CRYPTO_KEY = 'crypto-key';
+ const tree = { id: 10, children: [], files: [], plainName: 'Root' };
+ const expectedSnapshot = { tree, size: 0, folderDecryptedNames: {}, fileDecryptedNames: {} };
+
+ fetchFolderTreeByUuidMock.mockResolvedValue({ data: { tree } } as never);
+ buildBackupFolderTreeSnapshotMock.mockImplementation(({ decryptFileName }) => {
+ decryptFileName('encrypted-name', 10);
+ return expectedSnapshot as never;
+ });
+ aesDecryptMock.mockReturnValue('decrypted-name');
+
+ const result = await getBackupFolderTreeSnapshot({ folderUuid: 'folder-uuid' });
+
+ call(fetchFolderTreeByUuidMock).toStrictEqual({ uuid: 'folder-uuid' });
+ call(aesDecryptMock).toStrictEqual(['encrypted-name', 'crypto-key-10']);
+ expect(result).toStrictEqual({ data: expectedSnapshot });
+ });
+});
diff --git a/src/backend/features/backup/get-backup-folder-tree-snapshot.ts b/src/backend/features/backup/get-backup-folder-tree-snapshot.ts
new file mode 100644
index 0000000000..1325f5cb00
--- /dev/null
+++ b/src/backend/features/backup/get-backup-folder-tree-snapshot.ts
@@ -0,0 +1,27 @@
+import { aes } from '@internxt/lib';
+import { fetchFolderTreeByUuid } from '../../../infra/drive-server/services/folder/services/fetch-folder-tree-by-uuid';
+import { buildBackupFolderTreeSnapshot } from './build-backup-folder-tree-snapshot';
+import { BackupFolderTreeSnapshot } from './types/BackupFolderTreeSnapshot';
+import { Result } from '../../../context/shared/domain/Result';
+
+type Props = {
+ folderUuid: string;
+};
+
+export async function getBackupFolderTreeSnapshot({
+ folderUuid,
+}: Props): Promise> {
+ const { data, error } = await fetchFolderTreeByUuid({ uuid: folderUuid });
+
+ if (error) {
+ return { error: new Error('Unsuccesful request to fetch folder tree') };
+ }
+
+ const { tree } = data;
+ const backupFolderTreeSnapshot = buildBackupFolderTreeSnapshot({
+ tree,
+ decryptFileName: (name, folderId) => aes.decrypt(name, `${process.env.NEW_CRYPTO_KEY}-${folderId}`),
+ });
+
+ return { data: backupFolderTreeSnapshot };
+}
diff --git a/src/backend/features/backup/ipc/device-ipc-handlers.ts b/src/backend/features/backup/ipc/device-ipc-handlers.ts
new file mode 100644
index 0000000000..379d8e4637
--- /dev/null
+++ b/src/backend/features/backup/ipc/device-ipc-handlers.ts
@@ -0,0 +1,37 @@
+import { ipcMain } from 'electron';
+import { DeviceModule } from '../../device/device.module';
+import { addBackup } from '../add-backup';
+import { getPathFromDialog } from '../../../../core/utils/get-path-from-dialog';
+import { getActiveBackupDevices } from '../../device/get-active-backup-devices';
+import { createBackupsFromLocalPaths } from '../create-backups-from-local-paths';
+import { deleteBackup } from '../delete-backup';
+import { deleteDeviceBackups } from '../delete-device-backups';
+import { disableBackup } from '../disable-backup';
+import { changeBackupPath } from '../change-backup-path';
+import { downloadBackup } from '../download-backup';
+
+ipcMain.handle('devices.get-all', () => getActiveBackupDevices());
+
+ipcMain.handle('get-or-create-device', DeviceModule.getOrCreateDevice);
+
+ipcMain.handle('rename-device', (_, v) => DeviceModule.renameDevice(v));
+
+ipcMain.handle('get-backups-from-device', (_, d, c?) => DeviceModule.getBackupsFromDevice(d, c));
+
+ipcMain.handle('add-backup', () => addBackup());
+
+ipcMain.handle('add-multiple-backups', (_, folderPaths) => createBackupsFromLocalPaths({ folderPaths }));
+
+ipcMain.handle('download-backup', (_, device, pathname) => downloadBackup({ device, pathname }));
+
+ipcMain.handle('delete-backup', (_, v, c?) => deleteBackup({ backup: v, isCurrent: c }));
+
+ipcMain.handle('delete-backups-from-device', (_, v, c?) => deleteDeviceBackups({ device: v, isCurrent: c }));
+
+ipcMain.handle('disable-backup', (_, v) => disableBackup({ backup: v }));
+
+ipcMain.handle('change-backup-path', (_, { currentPath, newPath }) => changeBackupPath({ currentPath, newPath }));
+
+ipcMain.on('add-device-issue', (_, e) => DeviceModule.addUnknownDeviceIssue(e));
+
+ipcMain.handle('get-folder-path', () => getPathFromDialog());
diff --git a/src/backend/features/backup/local-tree/build-local-tree.test.ts b/src/backend/features/backup/local-tree/build-local-tree.test.ts
new file mode 100644
index 0000000000..9777bb31d0
--- /dev/null
+++ b/src/backend/features/backup/local-tree/build-local-tree.test.ts
@@ -0,0 +1,85 @@
+import type { Stats } from 'node:fs';
+import { AbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
+import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
+import * as safeStatModule from '../../../../infra/local-file-system/safe-stat';
+import { LocalTree } from '../../../../context/local/localTree/domain/LocalTree';
+import { buildLocalTree } from './build-local-tree';
+import * as traverseModule from './traverse';
+
+vi.mock(import('../../../../infra/local-file-system/safe-stat'));
+vi.mock(import('./traverse'));
+
+const safeStatMock = vi.mocked(safeStatModule.safeStat);
+const traverseMock = vi.mocked(traverseModule.traverse);
+
+function stats(type: 'file' | 'folder'): Stats {
+ return {
+ mtime: new Date('2026-01-01T00:00:00.000Z'),
+ isDirectory: () => type === 'folder',
+ } as Stats;
+}
+
+describe('buildLocalTree', () => {
+ const root = '/home/user/Backup' as AbsolutePath;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('builds a local tree for a readable root folder', async () => {
+ safeStatMock.mockResolvedValue({ data: stats('folder') });
+ traverseMock.mockResolvedValue({ data: { skippedItems: [] } });
+
+ const result = await buildLocalTree(root);
+
+ expect(result.data?.tree).toBeInstanceOf(LocalTree);
+ expect(result.data?.tree.root.path).toBe(root);
+ expect(result.data?.skippedItems).toStrictEqual([]);
+ expect(safeStatMock).toHaveBeenCalledWith(root);
+ expect(traverseMock).toHaveBeenCalledWith({ tree: result.data?.tree, currentFolder: root, rootFolder: root });
+ });
+
+ it('bubbles skipped items returned by traversal', async () => {
+ const skippedError = new DriveDesktopError('ACTION_NOT_PERMITTED', 'Symbolic links are skipped');
+ const skippedItems = [{ path: '/home/user/Backup/link' as AbsolutePath, error: skippedError }];
+
+ safeStatMock.mockResolvedValue({ data: stats('folder') });
+ traverseMock.mockResolvedValue({ data: { skippedItems } });
+
+ const result = await buildLocalTree(root);
+
+ expect(result.data?.skippedItems).toBe(skippedItems);
+ });
+
+ it('returns root stat errors without traversing', async () => {
+ const error = new DriveDesktopError('NOT_EXISTS', 'root does not exist');
+ safeStatMock.mockResolvedValue({ error });
+
+ const result = await buildLocalTree(root);
+
+ expect(result).toStrictEqual({ error });
+ expect(traverseMock).not.toHaveBeenCalled();
+ });
+
+ it('returns BAD_REQUEST when the root path is not a directory', async () => {
+ safeStatMock.mockResolvedValue({ data: stats('file') });
+
+ const result = await buildLocalTree(root);
+
+ expect(result.error).toBeInstanceOf(DriveDesktopError);
+ expect(result.error?.cause).toBe('BAD_REQUEST');
+ expect(result.error?.message).toBe(`${root} is not a directory`);
+ expect(traverseMock).not.toHaveBeenCalled();
+ });
+
+ it('returns traversal errors', async () => {
+ const error = new DriveDesktopError('UNKNOWN', 'traversal failed');
+
+ safeStatMock.mockResolvedValue({ data: stats('folder') });
+ traverseMock.mockResolvedValue({ error });
+
+ const result = await buildLocalTree(root);
+
+ expect(result).toStrictEqual({ error });
+ });
+});
diff --git a/src/backend/features/backup/local-tree/build-local-tree.ts b/src/backend/features/backup/local-tree/build-local-tree.ts
new file mode 100644
index 0000000000..dfee04e987
--- /dev/null
+++ b/src/backend/features/backup/local-tree/build-local-tree.ts
@@ -0,0 +1,26 @@
+import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
+import { Result } from '../../../../context/shared/domain/Result';
+import { safeStat } from '../../../../infra/local-file-system/safe-stat';
+import { AbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
+import { LocalTree } from '../../../../context/local/localTree/domain/LocalTree';
+import { traverse } from './traverse';
+
+export async function buildLocalTree(
+ folder: AbsolutePath,
+): Promise<
+ Result<{ tree: LocalTree; skippedItems: Array<{ path: AbsolutePath; error: DriveDesktopError }> }, DriveDesktopError>
+> {
+ const { data: root, error } = await safeStat(folder);
+ if (error) {
+ return { error };
+ }
+ if (!root.isDirectory()) {
+ return { error: new DriveDesktopError('BAD_REQUEST', `${folder} is not a directory`) };
+ }
+ const tree = new LocalTree(folder, root.mtime.getTime());
+ const result = await traverse({ tree, currentFolder: folder, rootFolder: folder });
+ if (result.error) {
+ return { error: result.error };
+ }
+ return { data: { tree, skippedItems: result.data.skippedItems } };
+}
diff --git a/src/backend/features/backup/local-tree/get-dirents-for-path.test.ts b/src/backend/features/backup/local-tree/get-dirents-for-path.test.ts
new file mode 100644
index 0000000000..73ab32ea3c
--- /dev/null
+++ b/src/backend/features/backup/local-tree/get-dirents-for-path.test.ts
@@ -0,0 +1,105 @@
+import type { Dirent, Stats } from 'node:fs';
+import { AbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
+import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
+import * as safeReadDirModule from '../../../../infra/local-file-system/safe-readdir';
+import * as safeStatModule from '../../../../infra/local-file-system/safe-stat';
+import { getDirentsForPath } from './get-dirents-for-path';
+
+vi.mock(import('../../../../infra/local-file-system/safe-readdir'));
+vi.mock(import('../../../../infra/local-file-system/safe-stat'));
+
+const safeReadDirMock = vi.mocked(safeReadDirModule.safeReadDir);
+const safeStatMock = vi.mocked(safeStatModule.safeStat);
+
+function dirent(name: string, type: 'file' | 'folder' | 'symlink'): Dirent {
+ return {
+ name,
+ isFile: () => type === 'file',
+ isDirectory: () => type === 'folder',
+ isSymbolicLink: () => type === 'symlink',
+ } as Dirent;
+}
+
+function stats(type: 'file' | 'folder', size = 0): Stats {
+ return {
+ size,
+ mtime: new Date('2026-01-01T00:00:00.000Z'),
+ isFile: () => type === 'file',
+ isDirectory: () => type === 'folder',
+ } as Stats;
+}
+
+describe('getDirentsForPath', () => {
+ const root = '/home/user/Backup' as AbsolutePath;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns files and folders with their absolute paths and stats', async () => {
+ const fileStats = stats('file', 1024);
+ const folderStats = stats('folder');
+
+ safeReadDirMock.mockResolvedValue({
+ data: [dirent('file.txt', 'file'), dirent('Photos', 'folder')],
+ });
+ safeStatMock.mockResolvedValueOnce({ data: fileStats }).mockResolvedValueOnce({ data: folderStats });
+
+ const result = await getDirentsForPath(root);
+
+ expect(result.data).toStrictEqual({
+ files: [{ path: '/home/user/Backup/file.txt', stats: fileStats }],
+ folders: [{ path: '/home/user/Backup/Photos', stats: folderStats }],
+ skippedItems: [],
+ });
+ expect(safeReadDirMock).toHaveBeenCalledWith(root);
+ expect(safeStatMock).toHaveBeenNthCalledWith(1, '/home/user/Backup/file.txt');
+ expect(safeStatMock).toHaveBeenNthCalledWith(2, '/home/user/Backup/Photos');
+ });
+
+ it('skips symbolic links without statting them', async () => {
+ safeReadDirMock.mockResolvedValue({
+ data: [dirent('shortcut', 'symlink')],
+ });
+
+ const result = await getDirentsForPath(root);
+
+ expect(result.data?.files).toStrictEqual([]);
+ expect(result.data?.folders).toStrictEqual([]);
+ expect(result.data?.skippedItems).toHaveLength(1);
+ expect(result.data?.skippedItems[0]).toMatchObject({
+ path: '/home/user/Backup/shortcut',
+ error: expect.objectContaining({
+ cause: 'ACTION_NOT_PERMITTED',
+ message: 'Symbolic links are skipped',
+ }),
+ });
+ expect(safeStatMock).not.toHaveBeenCalled();
+ });
+
+ it('adds stat failures to skipped items and keeps processing other entries', async () => {
+ const statError = new DriveDesktopError('NOT_EXISTS', 'missing item');
+ const fileStats = stats('file', 2048);
+
+ safeReadDirMock.mockResolvedValue({
+ data: [dirent('missing.txt', 'file'), dirent('valid.txt', 'file')],
+ });
+ safeStatMock.mockResolvedValueOnce({ error: statError }).mockResolvedValueOnce({ data: fileStats });
+
+ const result = await getDirentsForPath(root);
+
+ expect(result.data?.files).toStrictEqual([{ path: '/home/user/Backup/valid.txt', stats: fileStats }]);
+ expect(result.data?.folders).toStrictEqual([]);
+ expect(result.data?.skippedItems).toStrictEqual([{ path: '/home/user/Backup/missing.txt', error: statError }]);
+ });
+
+ it('returns a readdir error without statting entries', async () => {
+ const readError = new DriveDesktopError('INSUFFICIENT_PERMISSION', 'cannot read folder');
+ safeReadDirMock.mockResolvedValue({ error: readError });
+
+ const result = await getDirentsForPath(root);
+
+ expect(result).toStrictEqual({ error: readError });
+ expect(safeStatMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/backend/features/backup/local-tree/get-dirents-for-path.ts b/src/backend/features/backup/local-tree/get-dirents-for-path.ts
new file mode 100644
index 0000000000..c896311b45
--- /dev/null
+++ b/src/backend/features/backup/local-tree/get-dirents-for-path.ts
@@ -0,0 +1,43 @@
+import { AbsolutePath, createAbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
+import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
+import { Result } from '../../../../context/shared/domain/Result';
+import { safeReadDir } from '../../../../infra/local-file-system/safe-readdir';
+import { ExtendedDirent, ProcessedDirents } from './types';
+import { safeStat } from '../../../../infra/local-file-system/safe-stat';
+
+export async function getDirentsForPath(
+ absolutePath: AbsolutePath,
+): Promise> {
+ const files: Array = [];
+ const folders: Array = [];
+ const skippedItems: Array<{ path: AbsolutePath; error: DriveDesktopError }> = [];
+ const { data: dirents, error } = await safeReadDir(absolutePath);
+ if (error) return { error };
+
+ for (const dirent of dirents) {
+ const currentPath = createAbsolutePath(absolutePath.toString(), dirent.name);
+
+ if (dirent.isSymbolicLink()) {
+ skippedItems.push({
+ path: currentPath,
+ error: new DriveDesktopError('ACTION_NOT_PERMITTED', 'Symbolic links are skipped'),
+ });
+ continue;
+ }
+
+ // eslint-disable-next-line no-await-in-loop
+ const statResult = await safeStat(currentPath);
+ if (statResult.error) {
+ skippedItems.push({ path: currentPath, error: statResult.error });
+ continue;
+ }
+ if (statResult.data.isFile()) {
+ files.push({ path: currentPath, stats: statResult.data });
+ continue;
+ }
+ if (statResult.data.isDirectory()) {
+ folders.push({ path: currentPath, stats: statResult.data });
+ }
+ }
+ return { data: { files, folders, skippedItems } };
+}
diff --git a/src/backend/features/backup/local-tree/index.ts b/src/backend/features/backup/local-tree/index.ts
new file mode 100644
index 0000000000..fb6fc2753b
--- /dev/null
+++ b/src/backend/features/backup/local-tree/index.ts
@@ -0,0 +1,2 @@
+export { buildLocalTree } from './build-local-tree';
+export type { ExtendedDirent, ProcessedDirents } from './types';
diff --git a/src/backend/features/backup/local-tree/traverse.test.ts b/src/backend/features/backup/local-tree/traverse.test.ts
new file mode 100644
index 0000000000..b4fb2b912c
--- /dev/null
+++ b/src/backend/features/backup/local-tree/traverse.test.ts
@@ -0,0 +1,120 @@
+import type { Stats } from 'node:fs';
+import { AbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
+import { LocalTree } from '../../../../context/local/localTree/domain/LocalTree';
+import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
+import * as getDirentsForPathModule from './get-dirents-for-path';
+import { traverse } from './traverse';
+
+vi.mock(import('./get-dirents-for-path'));
+
+const getDirentsForPathMock = vi.mocked(getDirentsForPathModule.getDirentsForPath);
+
+function stats(type: 'file' | 'folder', size = 0): Stats {
+ return {
+ size,
+ mtime: new Date('2026-01-01T00:00:00.000Z'),
+ isFile: () => type === 'file',
+ isDirectory: () => type === 'folder',
+ } as Stats;
+}
+
+describe('traverse', () => {
+ const root = '/home/user/Backup' as AbsolutePath;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('adds files and folders to the tree recursively', async () => {
+ const tree = new LocalTree(root, Date.now());
+
+ getDirentsForPathMock
+ .mockResolvedValueOnce({
+ data: {
+ files: [{ path: '/home/user/Backup/file.txt' as AbsolutePath, stats: stats('file', 1024) }],
+ folders: [{ path: '/home/user/Backup/Photos' as AbsolutePath, stats: stats('folder') }],
+ skippedItems: [],
+ },
+ })
+ .mockResolvedValueOnce({
+ data: {
+ files: [{ path: '/home/user/Backup/Photos/photo.jpg' as AbsolutePath, stats: stats('file', 2048) }],
+ folders: [],
+ skippedItems: [],
+ },
+ });
+
+ const result = await traverse({ tree, currentFolder: root, rootFolder: root });
+
+ expect(result.data?.skippedItems).toStrictEqual([]);
+ expect(tree.files.map((file) => file.path)).toEqual(
+ expect.arrayContaining(['/home/user/Backup/file.txt', '/home/user/Backup/Photos/photo.jpg']),
+ );
+ expect(tree.folders.map((folder) => folder.path)).toEqual(
+ expect.arrayContaining([root, '/home/user/Backup/Photos']),
+ );
+ });
+
+ it('bubbles skipped items from current and child folders', async () => {
+ const tree = new LocalTree(root, Date.now());
+ const skippedAtRoot = {
+ path: '/home/user/Backup/link' as AbsolutePath,
+ error: new DriveDesktopError('ACTION_NOT_PERMITTED', 'Symbolic links are skipped'),
+ };
+ const skippedInChild = {
+ path: '/home/user/Backup/Photos/private.jpg' as AbsolutePath,
+ error: new DriveDesktopError('INSUFFICIENT_PERMISSION', 'Cannot read item'),
+ };
+
+ getDirentsForPathMock
+ .mockResolvedValueOnce({
+ data: {
+ files: [],
+ folders: [{ path: '/home/user/Backup/Photos' as AbsolutePath, stats: stats('folder') }],
+ skippedItems: [skippedAtRoot],
+ },
+ })
+ .mockResolvedValueOnce({
+ data: {
+ files: [],
+ folders: [],
+ skippedItems: [skippedInChild],
+ },
+ });
+
+ const result = await traverse({ tree, currentFolder: root, rootFolder: root });
+
+ expect(result.data?.skippedItems).toStrictEqual([skippedAtRoot, skippedInChild]);
+ });
+
+ it('returns an error when the root folder cannot be read', async () => {
+ const tree = new LocalTree(root, Date.now());
+ const error = new DriveDesktopError('INSUFFICIENT_PERMISSION', 'Cannot read root');
+ getDirentsForPathMock.mockResolvedValueOnce({ error });
+
+ const result = await traverse({ tree, currentFolder: root, rootFolder: root });
+
+ expect(result).toStrictEqual({ error });
+ });
+
+ it('skips a nested folder when it cannot be read', async () => {
+ const tree = new LocalTree(root, Date.now());
+ const child = '/home/user/Backup/Photos' as AbsolutePath;
+ const error = new DriveDesktopError('INSUFFICIENT_PERMISSION', 'Cannot read child');
+
+ getDirentsForPathMock
+ .mockResolvedValueOnce({
+ data: {
+ files: [],
+ folders: [{ path: child, stats: stats('folder') }],
+ skippedItems: [],
+ },
+ })
+ .mockResolvedValueOnce({ error });
+
+ const result = await traverse({ tree, currentFolder: root, rootFolder: root });
+
+ expect(result.data?.skippedItems).toStrictEqual([{ path: child, error }]);
+ expect(tree.folders.map((folder) => folder.path)).toContain(child);
+ });
+});
diff --git a/src/backend/features/backup/local-tree/traverse.ts b/src/backend/features/backup/local-tree/traverse.ts
new file mode 100644
index 0000000000..bc35d15cb7
--- /dev/null
+++ b/src/backend/features/backup/local-tree/traverse.ts
@@ -0,0 +1,145 @@
+import { logger } from '@internxt/drive-desktop-core/build/backend';
+import { AbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
+import { LocalTree } from '../../../../context/local/localTree/domain/LocalTree';
+import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
+import { Result } from '../../../../context/shared/domain/Result';
+
+import { ExtendedDirent, ProcessedDirents } from './types';
+import { LocalFile } from '../../../../context/local/localFile/domain/LocalFile';
+import { LocalFolder } from '../../../../context/local/localFolder/domain/LocalFolder';
+import { getDirentsForPath } from './get-dirents-for-path';
+
+type TraverseResult = Pick;
+
+type Props = {
+ tree: LocalTree;
+ currentFolder: AbsolutePath;
+ rootFolder: AbsolutePath;
+};
+
+export async function traverse({
+ tree,
+ currentFolder,
+ rootFolder,
+}: Props): Promise> {
+ const { data, error } = await getDirentsForPath(currentFolder);
+ if (error) {
+ return handleReadError({ currentFolder, rootFolder, error });
+ }
+
+ if (data.skippedItems.length > 0) {
+ logger.warn({
+ tag: 'BACKUPS',
+ msg: 'Skipped Local backup items',
+ pathname: currentFolder,
+ skippedItems: data.skippedItems.map((item) => ({ path: item.path, error: item.error.message })),
+ });
+ }
+
+ const skippedItems = [...data.skippedItems];
+
+ const { data: currentLocalFolder, error: currentLocalFolderError } = getCurrentLocalFolder({ tree, currentFolder });
+ if (currentLocalFolderError) {
+ return { error: currentLocalFolderError };
+ }
+
+ addFilesToTree({ tree, currentLocalFolder, files: data.files });
+
+ const { data: childFolderResult, error: childFolderError } = await addFoldersToTreeAndTraverseChildren({
+ tree,
+ currentLocalFolder,
+ folders: data.folders,
+ rootFolder,
+ });
+ if (childFolderError) {
+ return { error: childFolderError };
+ }
+ skippedItems.push(...childFolderResult.skippedItems);
+
+ return { data: { skippedItems } };
+}
+
+function handleReadError({
+ currentFolder,
+ rootFolder,
+ error,
+}: {
+ currentFolder: AbsolutePath;
+ rootFolder: AbsolutePath;
+ error: DriveDesktopError;
+}): Result {
+ if (currentFolder === rootFolder) {
+ return { error };
+ }
+
+ logger.warn({
+ tag: 'BACKUPS',
+ msg: 'Skipped unreadable local backup folder',
+ pathname: currentFolder,
+ error: error.message,
+ });
+
+ return { data: { skippedItems: [{ path: currentFolder, error }] } };
+}
+
+function getCurrentLocalFolder({
+ tree,
+ currentFolder,
+}: {
+ tree: LocalTree;
+ currentFolder: AbsolutePath;
+}): Result {
+ const folder = tree.folders.find((folder) => folder.path === currentFolder);
+
+ if (!folder) {
+ const error = new DriveDesktopError('UNKNOWN', `Current folder ${currentFolder} not found in the tree`);
+ logger.error({ tag: 'BACKUPS', msg: 'Error traversing local tree', pathname: currentFolder, error });
+ return { error };
+ }
+
+ return { data: folder };
+}
+
+function addFilesToTree({
+ tree,
+ currentLocalFolder,
+ files,
+}: {
+ tree: LocalTree;
+ currentLocalFolder: LocalFolder;
+ files: ExtendedDirent[];
+}): void {
+ files.forEach(({ path, stats }) => {
+ const file = LocalFile.from({ path, modificationTime: stats.mtime.getTime(), size: stats.size });
+ tree.addFile(currentLocalFolder, file);
+ });
+}
+
+async function addFoldersToTreeAndTraverseChildren({
+ tree,
+ currentLocalFolder,
+ folders,
+ rootFolder,
+}: {
+ tree: LocalTree;
+ currentLocalFolder: LocalFolder;
+ folders: ExtendedDirent[];
+ rootFolder: AbsolutePath;
+}): Promise> {
+ const skippedItems: TraverseResult['skippedItems'] = [];
+
+ for (const { path, stats } of folders) {
+ const folder = LocalFolder.from(path, stats.mtime.getTime());
+ tree.addFolder(currentLocalFolder, folder);
+
+ // eslint-disable-next-line no-await-in-loop
+ const result = await traverse({ tree, currentFolder: path, rootFolder });
+ if (result.error) {
+ return { error: result.error };
+ }
+
+ skippedItems.push(...result.data.skippedItems);
+ }
+
+ return { data: { skippedItems } };
+}
diff --git a/src/backend/features/backup/local-tree/types.ts b/src/backend/features/backup/local-tree/types.ts
new file mode 100644
index 0000000000..7605cfe8bf
--- /dev/null
+++ b/src/backend/features/backup/local-tree/types.ts
@@ -0,0 +1,10 @@
+import { Stats } from 'node:fs';
+import { AbsolutePath } from '../../../../context/local/localFile/infrastructure/AbsolutePath';
+import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
+
+export type ExtendedDirent = { path: AbsolutePath; stats: Stats };
+export type ProcessedDirents = {
+ files: Array;
+ folders: Array;
+ skippedItems: Array<{ path: AbsolutePath; error: DriveDesktopError }>;
+};
diff --git a/src/backend/features/backup/migrate-backup-entry-if-needed.test.ts b/src/backend/features/backup/migrate-backup-entry-if-needed.test.ts
new file mode 100644
index 0000000000..eeef0aa7eb
--- /dev/null
+++ b/src/backend/features/backup/migrate-backup-entry-if-needed.test.ts
@@ -0,0 +1,38 @@
+import configStoreModule from '../../../apps/main/config';
+import * as getBackupFolderUuidModule from '../../../infra/drive-server/services/folder/services/fetch-backup-folder-uuid';
+import { logger } from '@internxt/drive-desktop-core/build/backend/core/logger/logger';
+import { call, partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { migrateBackupEntryIfNeeded } from './migrate-backup-entry-if-needed';
+
+describe('migrate-backup-entry-if-needed', () => {
+ const getBackupFolderUuidMock = partialSpyOn(getBackupFolderUuidModule, 'getBackupFolderUuid');
+ const configStoreGetMock = partialSpyOn(configStoreModule, 'get');
+ const configStoreSetMock = partialSpyOn(configStoreModule, 'set');
+ const loggerErrorMock = partialSpyOn(logger, 'error');
+
+ it('should migrate backup by fetching folder uuid and persisting it', async () => {
+ const pathname = '/home/dev/Documents';
+ const backup = { folderId: 1, folderUuid: '', enabled: true };
+ const backupList = { [pathname]: backup };
+
+ getBackupFolderUuidMock.mockResolvedValue({ data: 'new-folder-uuid' });
+ configStoreGetMock.mockReturnValue(backupList);
+
+ const result = await migrateBackupEntryIfNeeded({ pathname, backup });
+
+ expect(result.data?.folderUuid).toBe('new-folder-uuid');
+ call(configStoreSetMock).toStrictEqual(['backupList', backupList]);
+ });
+
+ it('should return error when folder uuid retrieval fails', async () => {
+ const error = new Error('uuid request failed');
+ const backup = { folderId: 1, folderUuid: '', enabled: true };
+
+ getBackupFolderUuidMock.mockResolvedValue({ error } as never);
+
+ const result = await migrateBackupEntryIfNeeded({ pathname: '/home/dev/Documents', backup });
+
+ expect(result.error?.message).toBe(error.message);
+ expect(loggerErrorMock).toBeCalled();
+ });
+});
diff --git a/src/backend/features/backup/migrate-backup-entry-if-needed.ts b/src/backend/features/backup/migrate-backup-entry-if-needed.ts
new file mode 100644
index 0000000000..d84446cbcc
--- /dev/null
+++ b/src/backend/features/backup/migrate-backup-entry-if-needed.ts
@@ -0,0 +1,35 @@
+import { logger } from '@internxt/drive-desktop-core/build/backend/core/logger/logger';
+import configStore from '../../../apps/main/config';
+import { getBackupFolderUuid } from '../../../infra/drive-server/services/folder/services/fetch-backup-folder-uuid';
+import { Result } from '../../../context/shared/domain/Result';
+import { BackupEntry } from './types/BackupEntry';
+
+type Props = {
+ pathname: string;
+ backup: BackupEntry;
+};
+
+export async function migrateBackupEntryIfNeeded({ pathname, backup }: Props): Promise> {
+ const { error, data: folderUuid } = await getBackupFolderUuid({ folderId: String(backup.folderId) });
+ if (error) {
+ logger.error({
+ tag: 'BACKUPS',
+ msg: `Failed to migrate backup entry for ${pathname}`,
+ error,
+ });
+ return { error };
+ }
+
+ backup.folderUuid = folderUuid;
+
+ const backupList = configStore.get('backupList');
+ backupList[pathname] = backup;
+ configStore.set('backupList', backupList);
+
+ logger.debug({
+ tag: 'BACKUPS',
+ msg: `Successfully migrated backup entry for ${pathname} with UUID ${folderUuid}`,
+ });
+
+ return { data: backup };
+}
diff --git a/src/backend/features/backup/precalculate-backup-item-count.test.ts b/src/backend/features/backup/precalculate-backup-item-count.test.ts
index f482b38040..d0a352dd4a 100644
--- a/src/backend/features/backup/precalculate-backup-item-count.test.ts
+++ b/src/backend/features/backup/precalculate-backup-item-count.test.ts
@@ -1,11 +1,14 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { left, right } from '../../../context/shared/domain/Either';
-import LocalTreeBuilder from '../../../context/local/localTree/application/LocalTreeBuilder';
import { RemoteTreeBuilder } from '../../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder';
import { DiffFilesCalculatorService } from '../../../apps/backups/diff/DiffFilesCalculatorService';
import { FoldersDiffCalculator } from '../../../apps/backups/diff/FoldersDiffCalculator';
import { precalculateBackupItemCount } from './precalculate-backup-item-count';
import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath';
+import * as buildLocalTreeModule from './local-tree/';
+import { LocalTreeMother } from '../../../context/local/localTree/domain/__test-helpers__/LocalTreeMother';
+import { DriveDesktopError } from '../../../context/shared/domain/errors/DriveDesktopError';
+
+vi.mock(import('./local-tree/'));
describe('precalculateBackupItemCount', () => {
const backupInfo = {
@@ -17,51 +20,37 @@ describe('precalculateBackupItemCount', () => {
name: 'Documents',
};
- const localTree = { root: { path: '/home/user/Documents' } };
const remoteTree = { root: { path: '/remote/Documents' } };
+ const buildLocalTreeMock = vi.mocked(buildLocalTreeModule.buildLocalTree);
- let localTreeBuilder: { run: ReturnType };
let remoteTreeBuilder: { run: ReturnType };
beforeEach(() => {
- localTreeBuilder = {
- run: vi.fn(),
- };
-
+ buildLocalTreeMock.mockResolvedValue({ data: { tree: LocalTreeMother.oneLevel(10), skippedItems: [] } });
remoteTreeBuilder = {
run: vi.fn(),
};
});
it('returns total item count when precalculation succeeds', async () => {
- localTreeBuilder.run.mockResolvedValue(right(localTree));
remoteTreeBuilder.run.mockResolvedValue(remoteTree);
vi.spyOn(DiffFilesCalculatorService, 'calculate').mockReturnValue({ total: 7 } as never);
vi.spyOn(FoldersDiffCalculator, 'calculate').mockReturnValue({ total: 3 } as never);
- const result = await precalculateBackupItemCount(
- backupInfo,
- localTreeBuilder as unknown as LocalTreeBuilder,
- remoteTreeBuilder as unknown as RemoteTreeBuilder,
- );
+ const result = await precalculateBackupItemCount(backupInfo, remoteTreeBuilder as unknown as RemoteTreeBuilder);
expect(result.data).toBe(10);
- expect(localTreeBuilder.run).toBeCalledWith(backupInfo.pathname);
expect(remoteTreeBuilder.run).toBeCalledWith(backupInfo.folderId, backupInfo.folderUuid, true);
});
it('returns an error when local tree build returns left', async () => {
- localTreeBuilder.run.mockResolvedValue(left(new Error('local tree error')));
+ buildLocalTreeMock.mockResolvedValueOnce({ error: new DriveDesktopError('NOT_EXISTS', 'local tree error') });
const filesSpy = vi.spyOn(DiffFilesCalculatorService, 'calculate');
const foldersSpy = vi.spyOn(FoldersDiffCalculator, 'calculate');
- const result = await precalculateBackupItemCount(
- backupInfo,
- localTreeBuilder as unknown as LocalTreeBuilder,
- remoteTreeBuilder as unknown as RemoteTreeBuilder,
- );
+ const result = await precalculateBackupItemCount(backupInfo, remoteTreeBuilder as unknown as RemoteTreeBuilder);
expect(result.error).toBeDefined();
expect(remoteTreeBuilder.run).not.toHaveBeenCalled();
@@ -69,29 +58,20 @@ describe('precalculateBackupItemCount', () => {
expect(foldersSpy).not.toHaveBeenCalled();
});
- it('returns an error when localTreeBuilder throws', async () => {
+ it('returns an error when buildLocalTree throws', async () => {
const runError = new Error('unexpected failure');
- localTreeBuilder.run.mockRejectedValue(runError);
+ buildLocalTreeMock.mockRejectedValueOnce(runError);
- const result = await precalculateBackupItemCount(
- backupInfo,
- localTreeBuilder as unknown as LocalTreeBuilder,
- remoteTreeBuilder as unknown as RemoteTreeBuilder,
- );
+ const result = await precalculateBackupItemCount(backupInfo, remoteTreeBuilder as unknown as RemoteTreeBuilder);
expect(result.error).toBe(runError);
});
it('returns an error when remoteTreeBuilder throws', async () => {
const runError = new Error('remote failure');
- localTreeBuilder.run.mockResolvedValue(right(localTree));
remoteTreeBuilder.run.mockRejectedValue(runError);
- const result = await precalculateBackupItemCount(
- backupInfo,
- localTreeBuilder as unknown as LocalTreeBuilder,
- remoteTreeBuilder as unknown as RemoteTreeBuilder,
- );
+ const result = await precalculateBackupItemCount(backupInfo, remoteTreeBuilder as unknown as RemoteTreeBuilder);
expect(result.error).toBe(runError);
});
diff --git a/src/backend/features/backup/precalculate-backup-item-count.ts b/src/backend/features/backup/precalculate-backup-item-count.ts
index 98a18afde6..f182279ad3 100644
--- a/src/backend/features/backup/precalculate-backup-item-count.ts
+++ b/src/backend/features/backup/precalculate-backup-item-count.ts
@@ -1,27 +1,26 @@
import { BackupInfo } from '../../../apps/backups/BackupInfo';
import { DiffFilesCalculatorService } from '../../../apps/backups/diff/DiffFilesCalculatorService';
import { FoldersDiffCalculator } from '../../../apps/backups/diff/FoldersDiffCalculator';
-import LocalTreeBuilder from '../../../context/local/localTree/application/LocalTreeBuilder';
import { Result } from '../../../context/shared/domain/Result';
import { RemoteTreeBuilder } from '../../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder';
+import { buildLocalTree } from './local-tree';
export async function precalculateBackupItemCount(
backupInfo: BackupInfo,
- localTreeBuilder: LocalTreeBuilder,
remoteTreeBuilder: RemoteTreeBuilder,
): Promise> {
let localTreeEither;
try {
- localTreeEither = await localTreeBuilder.run(backupInfo.pathname);
+ localTreeEither = await buildLocalTree(backupInfo.pathname);
} catch (error) {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
- if (localTreeEither.isLeft()) {
+ if (localTreeEither.error) {
return { error: new Error('Error building local tree during precalculation') };
}
- const local = localTreeEither.getRight();
+ const local = localTreeEither.data;
let remote;
try {
@@ -30,8 +29,8 @@ export async function precalculateBackupItemCount(
return { error: error instanceof Error ? error : new Error(String(error)) };
}
- const filesDiff = DiffFilesCalculatorService.calculate(local, remote);
- const foldersDiff = FoldersDiffCalculator.calculate(local, remote);
+ const filesDiff = DiffFilesCalculatorService.calculate(local.tree, remote);
+ const foldersDiff = FoldersDiffCalculator.calculate(local.tree, remote);
return { data: filesDiff.total + foldersDiff.total };
}
diff --git a/src/backend/features/backup/types/BackupEntry.ts b/src/backend/features/backup/types/BackupEntry.ts
new file mode 100644
index 0000000000..2b653e264c
--- /dev/null
+++ b/src/backend/features/backup/types/BackupEntry.ts
@@ -0,0 +1,5 @@
+export type BackupEntry = {
+ enabled: boolean;
+ folderId: number;
+ folderUuid: string;
+};
diff --git a/src/backend/features/backup/types/BackupFolderTreeSnapshot.ts b/src/backend/features/backup/types/BackupFolderTreeSnapshot.ts
new file mode 100644
index 0000000000..41a119e5ee
--- /dev/null
+++ b/src/backend/features/backup/types/BackupFolderTreeSnapshot.ts
@@ -0,0 +1,8 @@
+import { FolderTree } from '@internxt/sdk/dist/drive/storage/types';
+
+export type BackupFolderTreeSnapshot = {
+ tree: FolderTree;
+ folderDecryptedNames: Record;
+ fileDecryptedNames: Record;
+ size: number;
+};
diff --git a/src/backend/features/backup/types/Device.ts b/src/backend/features/backup/types/Device.ts
new file mode 100644
index 0000000000..7983391bfa
--- /dev/null
+++ b/src/backend/features/backup/types/Device.ts
@@ -0,0 +1,8 @@
+export type Device = {
+ id: number;
+ uuid: string;
+ name: string;
+ bucket: string;
+ removed: boolean;
+ hasBackups: boolean;
+};
diff --git a/src/backend/features/backup/upload/backup-upload-error-handler.ts b/src/backend/features/backup/upload/backup-upload-error-handler.ts
deleted file mode 100644
index 0522a40a1b..0000000000
--- a/src/backend/features/backup/upload/backup-upload-error-handler.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { DriveDesktopError } from '../../../../context/shared/domain/errors/DriveDesktopError';
-import { logger } from '@internxt/drive-desktop-core/build/backend';
-import { INITIAL_RATE_LIMIT_DELAY_MS, MAX_BACKOFF_MS, RETRY_DELAYS_MS } from './constants';
-
-function exponentialBackoff(attempts: number, baseMs: number): number {
- return Math.min(baseMs * Math.pow(2, attempts - 1), MAX_BACKOFF_MS);
-}
-
-export function createBackupUploadErrorHandler(path: string): (error: DriveDesktopError) => number | null {
- let transientAttempts = 0;
-
- return (error: DriveDesktopError): number | null => {
- if (error.cause === 'RATE_LIMITED' || error.cause === 'INTERNAL_SERVER_ERROR') {
- transientAttempts++;
- const base =
- error.cause === 'RATE_LIMITED' ? Number(error.message) || INITIAL_RATE_LIMIT_DELAY_MS : RETRY_DELAYS_MS[0];
- const delayMs = exponentialBackoff(transientAttempts, base);
- logger.debug({
- tag: 'BACKUPS',
- msg: `[${error.cause}] Attempt ${transientAttempts}, waiting ${delayMs}ms`,
- path,
- });
- return delayMs;
- }
-
- return null;
- };
-}
diff --git a/src/backend/features/backup/upload/constants.ts b/src/backend/features/backup/upload/constants.ts
index d6b001b6c1..c1f9df8015 100644
--- a/src/backend/features/backup/upload/constants.ts
+++ b/src/backend/features/backup/upload/constants.ts
@@ -1,5 +1 @@
export const DEFAULT_CONCURRENCY = 6;
-export const MAX_RETRIES = 3;
-export const RETRY_DELAYS_MS = [1000, 2000, 4000];
-export const INITIAL_RATE_LIMIT_DELAY_MS = 30_000;
-export const MAX_BACKOFF_MS = 480_000;
diff --git a/src/backend/features/backup/upload/create-backup-update-executor.test.ts b/src/backend/features/backup/upload/create-backup-update-executor.test.ts
index bd9d683623..03e3ef2406 100644
--- a/src/backend/features/backup/upload/create-backup-update-executor.test.ts
+++ b/src/backend/features/backup/upload/create-backup-update-executor.test.ts
@@ -4,6 +4,7 @@ import { FileMother } from '../../../../context/virtual-drive/files/domain/__tes
import { LocalFileMother } from '../../../../context/local/localFile/domain/__test-helpers__/LocalFileMother';
import { BackupProgressTracker } from '../backup-progress-tracker';
import { mockDeep } from 'vitest-mock-extended';
+import { Environment } from '@internxt/inxt-js';
import { createBackupUpdateExecutor, ModifiedFilePair } from './create-backup-update-executor';
import * as updateFileToBackupModule from './update-file-to-backup';
import * as backupErrorsTrackerModule from '..';
@@ -14,14 +15,16 @@ describe('createBackupUpdateExecutor', () => {
let tracker: BackupProgressTracker;
let abortController: AbortController;
+ let environment: Environment;
beforeEach(() => {
tracker = mockDeep();
abortController = new AbortController();
+ environment = mockDeep();
});
function createExecutor() {
- return createBackupUpdateExecutor('bucket', {} as any, tracker);
+ return createBackupUpdateExecutor('bucket', environment, tracker);
}
function createPair(): ModifiedFilePair {
@@ -82,7 +85,7 @@ describe('createBackupUpdateExecutor', () => {
size: localFile.size,
bucket: 'bucket',
fileUuid: remoteFile.uuid,
- environment: {},
+ environment,
signal: abortController.signal,
});
});
diff --git a/src/backend/features/backup/upload/update-file-to-backup.test.ts b/src/backend/features/backup/upload/update-file-to-backup.test.ts
index 64e6cf00c9..47f79decad 100644
--- a/src/backend/features/backup/upload/update-file-to-backup.test.ts
+++ b/src/backend/features/backup/upload/update-file-to-backup.test.ts
@@ -56,6 +56,16 @@ describe('update-file-to-backup', () => {
expect(overrideFileMock).not.toHaveBeenCalled();
});
+ it('should return ACTION_NOT_PERMITTED when content upload cannot read the local file', async () => {
+ const uploadError = new DriveDesktopError('ACTION_NOT_PERMITTED', 'permission denied');
+ uploadContentMock.mockResolvedValue({ error: uploadError });
+
+ const result = await updateFileToBackup({ ...baseParams, signal: abortController.signal });
+
+ expect(result.error).toBe(uploadError);
+ expect(overrideFileMock).not.toHaveBeenCalled();
+ });
+
it('should return error when override fails with non-retryable error', async () => {
uploadContentMock.mockResolvedValue({ data: BucketEntryIdMother.primitive() });
overrideFileMock.mockResolvedValue({ error: new DriveServerError('NOT_FOUND', 404) });
diff --git a/src/backend/features/backup/upload/update-file-to-backup.ts b/src/backend/features/backup/upload/update-file-to-backup.ts
index 0143f30126..1d15e2b6c6 100644
--- a/src/backend/features/backup/upload/update-file-to-backup.ts
+++ b/src/backend/features/backup/upload/update-file-to-backup.ts
@@ -3,7 +3,7 @@ import { DriveDesktopError } from '../../../../context/shared/domain/errors/Driv
import { Result } from '../../../../context/shared/domain/Result';
import { uploadContentToEnvironment } from './upload-content-to-environment';
import { retryWithBackoff } from '../../../../shared/retry-with-backoff';
-import { createBackupUploadErrorHandler } from './backup-upload-error-handler';
+import { createTransientErrorHandler } from '../../../../backend/common/rate-limit/transient-error-handler';
import { overrideFileToBackend } from './override-file-to-backend';
export type UpdateFileParams = {
@@ -25,7 +25,7 @@ async function updateFile(file: UpdateFileParams): Promise ({
+ safeAccess: vi.fn(),
+}));
describe('upload-content-to-environment', () => {
const createReadStreamMock = deepMocked(fs.createReadStream);
+ const safeAccessMock = vi.mocked(safeAccessModule.safeAccess);
const SMALL_SIZE = 1024;
const LARGE_SIZE = 200 * 1024 * 1024; // > 100MB threshold
@@ -31,8 +37,10 @@ describe('upload-content-to-environment', () => {
beforeEach(() => {
abortController = new AbortController();
+ capturedOpts = undefined as unknown as UploadOptions;
fakeStream = Object.assign(new Readable({ read() {} }), { close: vi.fn(), destroy: vi.fn() });
createReadStreamMock.mockReturnValue(fakeStream as ReturnType);
+ safeAccessMock.mockResolvedValue({ data: undefined });
environment = {
upload: makeUploadFn(),
@@ -50,13 +58,22 @@ describe('upload-content-to-environment', () => {
});
}
+ async function startUpload(size = SMALL_SIZE) {
+ const promise = callUpload(size);
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ return { promise };
+ }
+
function triggerFinished(err: Error | null, contentsId: string | null) {
capturedOpts.finishedCallback(err, contentsId);
}
it('should resolve with contentsId on successful upload', async () => {
const contentsId = 'abc123';
- const promise = callUpload();
+ const { promise } = await startUpload();
+ expect(capturedOpts).toBeDefined();
triggerFinished(null, contentsId);
const result = await promise;
@@ -66,7 +83,7 @@ describe('upload-content-to-environment', () => {
});
it('should use upload for files below multipart threshold', async () => {
- const promise = callUpload(SMALL_SIZE);
+ const { promise } = await startUpload(SMALL_SIZE);
triggerFinished(null, 'id');
await promise;
@@ -76,7 +93,7 @@ describe('upload-content-to-environment', () => {
});
it('should use uploadMultipartFile for files above multipart threshold', async () => {
- const promise = callUpload(LARGE_SIZE);
+ const { promise } = await startUpload(LARGE_SIZE);
triggerFinished(null, 'id');
await promise;
@@ -86,7 +103,7 @@ describe('upload-content-to-environment', () => {
});
it('should return NOT_ENOUGH_SPACE error when upload fails with "Max space used"', async () => {
- const promise = callUpload();
+ const { promise } = await startUpload();
triggerFinished(new Error('Max space used'), null);
const result = await promise;
@@ -95,7 +112,7 @@ describe('upload-content-to-environment', () => {
});
it('should return RATE_LIMITED error on 429 with retry_after from message', async () => {
- const promise = callUpload();
+ const { promise } = await startUpload();
const err = Object.assign(new Error(JSON.stringify({ retry_after: 10 })), { status: 429 });
triggerFinished(err, null);
@@ -106,7 +123,7 @@ describe('upload-content-to-environment', () => {
});
it('should return RATE_LIMITED with default delay when retry_after is missing', async () => {
- const promise = callUpload();
+ const { promise } = await startUpload();
const err = Object.assign(new Error('{}'), { status: 429 });
triggerFinished(err, null);
@@ -117,7 +134,7 @@ describe('upload-content-to-environment', () => {
});
it('should return INTERNAL_SERVER_ERROR on 500+ errors', async () => {
- const promise = callUpload();
+ const { promise } = await startUpload();
const err = Object.assign(new Error('Server error'), { status: 500 });
triggerFinished(err, null);
@@ -127,7 +144,7 @@ describe('upload-content-to-environment', () => {
});
it('should return UNKNOWN error for generic errors', async () => {
- const promise = callUpload();
+ const { promise } = await startUpload();
triggerFinished(new Error('Something went wrong'), null);
const result = await promise;
@@ -136,7 +153,7 @@ describe('upload-content-to-environment', () => {
});
it('should return UNKNOWN error when contentsId is null on success', async () => {
- const promise = callUpload();
+ const { promise } = await startUpload();
triggerFinished(null, null);
const result = await promise;
@@ -154,11 +171,54 @@ describe('upload-content-to-environment', () => {
expect(result.error?.cause).toBe('UNKNOWN');
});
+ it('should return the access error without starting an upload when the file is not readable', async () => {
+ const accessError = new DriveDesktopError('ACTION_NOT_PERMITTED', 'permission denied');
+ safeAccessMock.mockResolvedValue({ error: accessError });
+
+ const result = await callUpload();
+
+ expect(result.error).toBe(accessError);
+ expect(createReadStreamMock).not.toHaveBeenCalled();
+ expect(environment.upload).not.toHaveBeenCalled();
+ expect(environment.uploadMultipartFile).not.toHaveBeenCalled();
+ });
+
+ it('should return ACTION_NOT_PERMITTED when createReadStream throws EACCES', async () => {
+ createReadStreamMock.mockImplementation(() => {
+ throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
+ });
+
+ const result = await callUpload();
+
+ expect(result.error?.cause).toBe('ACTION_NOT_PERMITTED');
+ expect(safeAccessMock).toHaveBeenCalledWith({ absolutePath: '/some/file.txt' });
+ expect(createReadStreamMock).toHaveBeenCalledWith('/some/file.txt');
+ expect(environment.upload).not.toHaveBeenCalled();
+ expect(environment.uploadMultipartFile).not.toHaveBeenCalled();
+ expect(result.data).toBeUndefined();
+ });
+
+ it('should return ACTION_NOT_PERMITTED when the read stream emits EACCES', async () => {
+ const { promise } = await startUpload();
+
+ fakeStream.emit('error', Object.assign(new Error('permission denied'), { code: 'EACCES' }));
+ triggerFinished(null, 'contents-id-after-stream-error');
+
+ const result = await promise;
+
+ expect(result.error?.cause).toBe('ACTION_NOT_PERMITTED');
+ expect(safeAccessMock).toHaveBeenCalledWith({ absolutePath: '/some/file.txt' });
+ expect(createReadStreamMock).toHaveBeenCalledWith('/some/file.txt');
+ expect(environment.upload).toHaveBeenCalledTimes(1);
+ expect(environment.uploadMultipartFile).not.toHaveBeenCalled();
+ expect(result.data).toBeUndefined();
+ });
+
it('should stop the upload and destroy the stream when signal is aborted', async () => {
const actionState = makeActionState();
(environment.upload as unknown as ReturnType) = makeUploadFn(actionState);
- callUpload();
+ await startUpload();
abortController.abort();
expect(actionState.stop).toHaveBeenCalled();
diff --git a/src/backend/features/backup/upload/upload-content-to-environment.ts b/src/backend/features/backup/upload/upload-content-to-environment.ts
index 21afcbca4d..f072b39c66 100644
--- a/src/backend/features/backup/upload/upload-content-to-environment.ts
+++ b/src/backend/features/backup/upload/upload-content-to-environment.ts
@@ -5,7 +5,9 @@ import { createReadStream } from 'node:fs';
import { UploadStrategyFunction } from '@internxt/inxt-js/build/lib/core';
import { Result } from '../../../../context/shared/domain/Result';
import { logger } from '@internxt/drive-desktop-core/build/backend';
-import { extractPropertyFromStringyfiedJson } from '../../../../shared/extract-property-from-json';
+import { isError } from '../../../../shared/errors/is-error';
+import { safeAccess } from '../../../../infra/local-file-system/safe-access';
+import { mapEnvironmentUploadError } from '../../../../backend/common/rate-limit/transient-error-handler';
export type ContentUploadParams = {
path: string;
@@ -14,24 +16,8 @@ export type ContentUploadParams = {
environment: Environment;
signal: AbortSignal;
};
-function mapUploadError(err: Error & { status?: unknown }): DriveDesktopError {
- if (err.message === 'Max space used') {
- return new DriveDesktopError('NOT_ENOUGH_SPACE');
- }
- if (typeof err.status === 'number') {
- if (err.status === 429) {
- const retryAfter = extractPropertyFromStringyfiedJson(err.message, 'retry_after');
- const retryAfterMs = typeof retryAfter === 'number' ? retryAfter * 1000 : 30_000;
- return new DriveDesktopError('RATE_LIMITED', String(retryAfterMs));
- }
- if (err.status >= 500) {
- return new DriveDesktopError('INTERNAL_SERVER_ERROR');
- }
- }
- return new DriveDesktopError('UNKNOWN');
-}
-export function uploadContentToEnvironment({
+export async function uploadContentToEnvironment({
path,
size,
bucket,
@@ -44,9 +30,26 @@ export function uploadContentToEnvironment({
? environment.uploadMultipartFile.bind(environment)
: environment.upload.bind(environment);
+ const accessResult = await safeAccess({ absolutePath: path });
+ if (accessResult.error) {
+ return { error: accessResult.error };
+ }
+
const readable = createReadStream(path);
return new Promise>((resolve) => {
+ let settled = false;
+ const resolveOnce = (result: Result) => {
+ if (settled) return;
+ settled = true;
+ resolve(result);
+ };
+
+ readable.on('error', (err: Error & { code?: unknown; status?: unknown }) => {
+ logger.error({ tag: 'BACKUPS', msg: '[ENVLFU READ STREAM ERROR]', err, path });
+ resolveOnce({ error: mapEnvironmentUploadError(err) });
+ });
+
const state = uploadFn(bucket, {
source: readable,
fileSize: size,
@@ -55,15 +58,15 @@ export function uploadContentToEnvironment({
if (err) {
logger.error({ tag: 'BACKUPS', msg: '[ENVLFU UPLOAD ERROR]', err });
- return resolve({ error: mapUploadError(err) });
+ return resolveOnce({ error: mapEnvironmentUploadError(err) });
}
if (!contentsId) {
logger.error({ tag: 'BACKUPS', msg: '[ENVLFU UPLOAD ERROR] No contentsId returned' });
- return resolve({ error: new DriveDesktopError('UNKNOWN') });
+ return resolveOnce({ error: new DriveDesktopError('UNKNOWN') });
}
- resolve({ data: contentsId });
+ resolveOnce({ data: contentsId });
},
progressCallback: (progress: number) => {
logger.debug({ tag: 'SYNC-ENGINE', msg: '[UPLOAD PROGRESS]', progress });
@@ -79,7 +82,10 @@ export function uploadContentToEnvironment({
{ once: true },
);
});
- } catch {
- return Promise.resolve({ error: new DriveDesktopError('UNKNOWN') });
+ } catch (err) {
+ if (isError(err)) {
+ return { error: mapEnvironmentUploadError(err) };
+ }
+ return { error: new DriveDesktopError('UNKNOWN') };
}
}
diff --git a/src/backend/features/backup/upload/upload-file-to-backup.test.ts b/src/backend/features/backup/upload/upload-file-to-backup.test.ts
index 396b311d6b..2a932e5eeb 100644
--- a/src/backend/features/backup/upload/upload-file-to-backup.test.ts
+++ b/src/backend/features/backup/upload/upload-file-to-backup.test.ts
@@ -50,6 +50,16 @@ describe('upload-file-to-backup', () => {
expect(createFileToBackendMock).not.toHaveBeenCalled();
});
+ it('should return ACTION_NOT_PERMITTED when content upload cannot read the local file', async () => {
+ const uploadError = new DriveDesktopError('ACTION_NOT_PERMITTED', 'permission denied');
+ uploadContentMock.mockResolvedValue({ error: uploadError });
+
+ const result = await uploadFileToBackup({ ...baseParams, signal: abortController.signal });
+
+ expect(result.error).toBe(uploadError);
+ expect(createFileToBackendMock).not.toHaveBeenCalled();
+ });
+
it('should return error when metadata creation fails and delete the uploaded content', async () => {
const contentsId = 'contents-id-123';
const metadataError = new DriveDesktopError('BAD_RESPONSE', 'Metadata failed');
diff --git a/src/backend/features/backup/upload/upload-file-to-backup.ts b/src/backend/features/backup/upload/upload-file-to-backup.ts
index 1db8f6deb4..73eefc9d2c 100644
--- a/src/backend/features/backup/upload/upload-file-to-backup.ts
+++ b/src/backend/features/backup/upload/upload-file-to-backup.ts
@@ -7,7 +7,7 @@ import { uploadContentToEnvironment } from './upload-content-to-environment';
import { Result } from '../../../../context/shared/domain/Result';
import { deleteFileFromStorageByFileId } from '../../../../infra/drive-server/services/files/services/delete-file-content-from-bucket';
import { retryWithBackoff } from '../../../../shared/retry-with-backoff';
-import { createBackupUploadErrorHandler } from './backup-upload-error-handler';
+import { createTransientErrorHandler } from '../../../../backend/common/rate-limit/transient-error-handler';
export type UploadFileParams = {
path: string;
@@ -29,7 +29,7 @@ async function uploadFile(file: UploadFileParams): Promise {
const isProfileDir = await isFirefoxProfileDirectory(entry, firefoxCacheDir);
- return { entry: entry, isProfileDir };
+ return { entry, isProfileDir };
}),
);
diff --git a/src/backend/features/device/createNewDevice.ts b/src/backend/features/device/createNewDevice.ts
index 026ffb2942..d094f5e987 100644
--- a/src/backend/features/device/createNewDevice.ts
+++ b/src/backend/features/device/createNewDevice.ts
@@ -1,5 +1,5 @@
import { Either, right } from './../../../context/shared/domain/Either';
-import { Device } from '../../../apps/main/device/service';
+import { Device } from '../backup/types/Device';
import { createUniqueDevice } from './createUniqueDevice';
import { saveDeviceToConfig } from './saveDeviceToConfig';
import { DeviceIdentifierDTO } from './device.types';
diff --git a/src/backend/features/device/createUniqueDevice.ts b/src/backend/features/device/createUniqueDevice.ts
index e90de230d0..5cc6304044 100644
--- a/src/backend/features/device/createUniqueDevice.ts
+++ b/src/backend/features/device/createUniqueDevice.ts
@@ -1,5 +1,5 @@
-import { Device } from '../../../apps/main/device/service';
-import os from 'os';
+import { Device } from '../backup/types/Device';
+import { hostname } from 'node:os';
import { logger } from '@internxt/drive-desktop-core/build/backend';
import { tryCreateDevice } from './tryCreateDevice';
import { Either, left, right } from '../../../context/shared/domain/Either';
@@ -14,7 +14,7 @@ export async function createUniqueDevice(
deviceIdentifier: DeviceIdentifierDTO,
attempts = 1000,
): Promise> {
- const baseName = os.hostname();
+ const baseName = hostname();
const nameVariants = [baseName, ...Array.from({ length: attempts }, (_, i) => `${baseName} (${i + 1})`)];
for (const name of nameVariants) {
diff --git a/src/backend/features/device/get-active-backup-devices.test.ts b/src/backend/features/device/get-active-backup-devices.test.ts
new file mode 100644
index 0000000000..ed92ddcbb1
--- /dev/null
+++ b/src/backend/features/device/get-active-backup-devices.test.ts
@@ -0,0 +1,30 @@
+import { driveServerModule } from '../../../infra/drive-server/drive-server.module';
+import { partialSpyOn } from '../../../../tests/vitest/utils.helper';
+import { getActiveBackupDevices } from './get-active-backup-devices';
+
+describe('get-active-backup-devices', () => {
+ const getDevicesMock = partialSpyOn(driveServerModule.backup, 'getDevices');
+
+ it('should return only active devices with backups', async () => {
+ getDevicesMock.mockResolvedValue({
+ isLeft: () => false,
+ getRight: () => [
+ { id: 1, uuid: '1', name: 'a', bucket: 'b', removed: false, hasBackups: true },
+ { id: 2, uuid: '2', name: 'b', bucket: 'b', removed: true, hasBackups: true },
+ { id: 3, uuid: '3', name: 'c', bucket: 'b', removed: false, hasBackups: false },
+ ],
+ } as never);
+
+ const result = await getActiveBackupDevices();
+
+ expect(result).toStrictEqual([{ id: 1, uuid: '1', name: 'a', bucket: 'b', removed: false, hasBackups: true }]);
+ });
+
+ it('should return empty array when service returns left response', async () => {
+ getDevicesMock.mockResolvedValue({ isLeft: () => true, getLeft: () => new Error('left error') } as never);
+
+ const result = await getActiveBackupDevices();
+
+ expect(result).toStrictEqual([]);
+ });
+});
diff --git a/src/backend/features/device/get-active-backup-devices.ts b/src/backend/features/device/get-active-backup-devices.ts
new file mode 100644
index 0000000000..f21bac6b0e
--- /dev/null
+++ b/src/backend/features/device/get-active-backup-devices.ts
@@ -0,0 +1,14 @@
+import { driveServerModule } from '../../../infra/drive-server/drive-server.module';
+import type { Device } from '../backup/types/Device';
+import { logger } from '@internxt/drive-desktop-core/build/backend/core/logger/logger';
+
+export async function getActiveBackupDevices(): Promise> {
+ const response = await driveServerModule.backup.getDevices();
+ if (response.isLeft()) {
+ logger.error({ tag: 'BACKUPS', msg: 'Failed to fetch devices for backup', error: response.getLeft() });
+ return [];
+ }
+
+ const devices = response.getRight();
+ return devices.filter(({ removed, hasBackups }) => !removed && hasBackups).map((device) => device);
+}
diff --git a/src/backend/features/device/getBackupsFromDevice.ts b/src/backend/features/device/getBackupsFromDevice.ts
index 1154a008cc..460a3c307f 100644
--- a/src/backend/features/device/getBackupsFromDevice.ts
+++ b/src/backend/features/device/getBackupsFromDevice.ts
@@ -2,9 +2,10 @@ import { FolderDtoWithPathname } from './device.types';
import { fetchFolder } from '../../../infra/drive-server/services/folder/services/fetch-folder';
import configStore from '../../../apps/main/config';
import { BackupInfo } from './../../../apps/backups/BackupInfo';
-import { Device, findBackupPathnameFromId } from './../../../apps/main/device/service';
+import { Device } from '../backup/types/Device';
import { FolderDto } from '../../../infra/drive-server/out/dto';
import { mapFolderDtoToBackupInfo } from './utils/mapFolderDtoToBackupInfo';
+import { findBackupPathnameFromId } from '../backup/find-backup-pathname-from-id';
export async function getBackupsFromDevice(device: Device, isCurrent?: boolean): Promise> {
const { data: folder, error } = await fetchFolder(device.uuid);
@@ -16,7 +17,7 @@ export async function getBackupsFromDevice(device: Device, isCurrent?: boolean):
const result = folder.children
.map((backup: FolderDto) => ({
...backup,
- pathname: findBackupPathnameFromId(backup.id),
+ pathname: findBackupPathnameFromId({ id: backup.id }),
}))
.filter((backup): backup is FolderDtoWithPathname => {
return !!(backup.pathname && backupsList[backup.pathname]?.enabled);
diff --git a/src/backend/features/device/getOrCreateDevice.ts b/src/backend/features/device/getOrCreateDevice.ts
index 101b4f69bb..170292fce4 100644
--- a/src/backend/features/device/getOrCreateDevice.ts
+++ b/src/backend/features/device/getOrCreateDevice.ts
@@ -1,4 +1,4 @@
-import { Device } from '../../../apps/main/device/service';
+import { Device } from '../backup/types/Device';
import configStore from '../../../apps/main/config';
import { logger } from '@internxt/drive-desktop-core/build/backend';
import { addUnknownDeviceIssue } from './addUnknownDeviceIssue';
diff --git a/src/backend/features/device/migrateLegacyDeviceIdentifier.ts b/src/backend/features/device/migrateLegacyDeviceIdentifier.ts
index 05854de1db..217966be7b 100644
--- a/src/backend/features/device/migrateLegacyDeviceIdentifier.ts
+++ b/src/backend/features/device/migrateLegacyDeviceIdentifier.ts
@@ -1,6 +1,6 @@
import { logger } from '@internxt/drive-desktop-core/build/backend';
import { driveServerModule } from './../../../infra/drive-server/drive-server.module';
-import { Device } from './../../../apps/main/device/service';
+import { Device } from '../backup/types/Device';
import { getDeviceIdentifier } from './getDeviceIdentifier';
import configStore from './../../../apps/main/config';
import { BackupError } from '../../../infra/drive-server/services/backup/backup.error';
diff --git a/src/backend/features/device/renameDevice.ts b/src/backend/features/device/renameDevice.ts
index 2c05eddcbe..e2e86f3b80 100644
--- a/src/backend/features/device/renameDevice.ts
+++ b/src/backend/features/device/renameDevice.ts
@@ -1,14 +1,14 @@
-import { Device } from '../../../apps/main/device/service';
+import { Device } from '../backup/types/Device';
import { driveServerModule } from '../../../infra/drive-server/drive-server.module';
import { getDeviceIdentifier } from './getDeviceIdentifier';
export async function renameDevice(deviceName: string): Promise {
const deviceIdentifier = getDeviceIdentifier();
- if (deviceIdentifier.isLeft()) {
+ if (deviceIdentifier.error) {
throw new Error('Error in the request to rename a device');
}
- const response = await driveServerModule.backup.updateDeviceByIdentifier(deviceIdentifier.getRight().key, deviceName);
+ const response = await driveServerModule.backup.updateDeviceByIdentifier(deviceIdentifier.data.key, deviceName);
if (response.isRight()) {
return response.getRight();
} else {
diff --git a/src/backend/features/device/saveDeviceToConfig.ts b/src/backend/features/device/saveDeviceToConfig.ts
index 50d7899356..da116eea71 100644
--- a/src/backend/features/device/saveDeviceToConfig.ts
+++ b/src/backend/features/device/saveDeviceToConfig.ts
@@ -1,5 +1,5 @@
import configStore from '../../../apps/main/config';
-import { Device } from '../../../apps/main/device/service';
+import { Device } from '../backup/types/Device';
export function saveDeviceToConfig(device: Device) {
configStore.set('deviceId', -1);
diff --git a/src/backend/features/device/tryCreateDevice.ts b/src/backend/features/device/tryCreateDevice.ts
index 536835048e..bf15078ce5 100644
--- a/src/backend/features/device/tryCreateDevice.ts
+++ b/src/backend/features/device/tryCreateDevice.ts
@@ -1,4 +1,4 @@
-import { Device } from './../../../apps/main/device/service';
+import { Device } from '../backup/types/Device';
import { left, right } from './../../../context/shared/domain/Either';
import { driveServerModule } from './../../../infra/drive-server/drive-server.module';
import { logger } from '@internxt/drive-desktop-core/build/backend';
diff --git a/src/backend/features/device/utils/deviceMapper.ts b/src/backend/features/device/utils/deviceMapper.ts
index 9a1c84c833..09dbb45cd7 100644
--- a/src/backend/features/device/utils/deviceMapper.ts
+++ b/src/backend/features/device/utils/deviceMapper.ts
@@ -1,5 +1,5 @@
import { components } from '../../../../infra/schemas';
-import { Device } from '../../../../apps/main/device/service';
+import { Device } from '../../backup/types/Device';
/**
* Maps a DeviceAsFolder from the API to the internal Device type
diff --git a/src/backend/features/fuse/on-read/constants.ts b/src/backend/features/fuse/on-read/constants.ts
new file mode 100644
index 0000000000..d0b25712aa
--- /dev/null
+++ b/src/backend/features/fuse/on-read/constants.ts
@@ -0,0 +1 @@
+export const EMPTY = Buffer.alloc(0);
diff --git a/src/backend/features/fuse/on-read/download-cache/allocate-file.test.ts b/src/backend/features/fuse/on-read/download-cache/allocate-file.test.ts
new file mode 100644
index 0000000000..88bed40bf3
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/allocate-file.test.ts
@@ -0,0 +1,54 @@
+import fs from 'node:fs/promises';
+import { allocateFile } from './allocate-file';
+
+vi.mock('node:fs/promises', () => ({
+ default: {
+ open: vi.fn(),
+ },
+}));
+
+const fsMock = vi.mocked(fs);
+
+function createHandle() {
+ return {
+ truncate: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ };
+}
+
+describe('allocateFile', () => {
+ it('opens the file for writing and truncates it to the requested size', async () => {
+ const handle = createHandle();
+ fsMock.open.mockResolvedValue(handle as unknown as Awaited>);
+
+ await allocateFile('/tmp/cache-file', 1024);
+
+ expect(fsMock.open).toHaveBeenCalledWith('/tmp/cache-file', 'w');
+ expect(handle.truncate).toHaveBeenCalledWith(1024);
+ });
+
+ it('closes the file handle after successful allocation', async () => {
+ const handle = createHandle();
+ fsMock.open.mockResolvedValue(handle as unknown as Awaited>);
+
+ await allocateFile('/tmp/cache-file', 1024);
+
+ expect(handle.close).toHaveBeenCalledOnce();
+ });
+
+ it('closes the file handle when truncate fails', async () => {
+ const handle = createHandle();
+ handle.truncate.mockRejectedValue(new Error('truncate failed'));
+ fsMock.open.mockResolvedValue(handle as unknown as Awaited>);
+
+ await expect(allocateFile('/tmp/cache-file', 1024)).rejects.toThrow('truncate failed');
+
+ expect(handle.close).toHaveBeenCalledOnce();
+ });
+
+ it('propagates open failures', async () => {
+ fsMock.open.mockRejectedValue(new Error('open failed'));
+
+ await expect(allocateFile('/tmp/cache-file', 1024)).rejects.toThrow('open failed');
+ });
+});
diff --git a/src/backend/features/fuse/on-read/download-cache/allocate-file.ts b/src/backend/features/fuse/on-read/download-cache/allocate-file.ts
new file mode 100644
index 0000000000..e8a1c1ae00
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/allocate-file.ts
@@ -0,0 +1,21 @@
+import fs from 'node:fs/promises';
+
+/**
+ * Pre-allocates a file on disk to the full expected size before any ranges are downloaded.
+ *
+ * This is necessary for random-access writes: since FUSE reads can arrive in any order,
+ * we need the file to exist at its full size so we can write each range at its correct
+ * byte offset. Without pre-allocation, writing at offset 500MB would fail because the
+ * file doesn't exist yet.
+ *
+ * The file is filled with zeros initially, the {@link rangeRegistry} tracks which regions
+ * contain real downloaded bytes vs unfilled zeros.
+ */
+export async function allocateFile(filePath: string, size: number): Promise {
+ const handle = await fs.open(filePath, 'w');
+ try {
+ await handle.truncate(size);
+ } finally {
+ await handle.close();
+ }
+}
diff --git a/src/backend/features/fuse/on-read/download-cache/constants.ts b/src/backend/features/fuse/on-read/download-cache/constants.ts
new file mode 100644
index 0000000000..1fc635e970
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/constants.ts
@@ -0,0 +1,7 @@
+/**
+ * 4MB blocks — matches the chunk size used by the legacy downloader, proven to work well
+ * for this codebase. Each block is downloaded in full on first access regardless of how
+ * small the FUSE read is, so subsequent reads within the same block are served from disk.
+ */
+export const BLOCK_SIZE = 4 * 1024 * 1024;
+export const BITS_PER_BYTE = 8;
diff --git a/src/backend/features/fuse/on-read/download-cache/download-and-save-block.test.ts b/src/backend/features/fuse/on-read/download-cache/download-and-save-block.test.ts
new file mode 100644
index 0000000000..f082fda5c4
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/download-and-save-block.test.ts
@@ -0,0 +1,214 @@
+import { type File } from '../../../../../context/virtual-drive/files/domain/File';
+import { downloadFileRange } from '../../../../../infra/environment/download-file/download-file';
+import { writeChunkToDisk } from '../read-chunk-from-disk';
+import { BLOCK_SIZE } from './constants';
+import { downloadAndCacheBlock } from './download-and-save-block';
+import {
+ clearHydrationState,
+ getOrCreateHydrationState,
+ isRangeHydrated,
+ markBlocksInRangeDownloaded,
+ type FileHydrationState,
+} from './hydration-state';
+
+vi.mock('../../../../../infra/environment/download-file/download-file', () => ({
+ downloadFileRange: vi.fn(),
+}));
+
+vi.mock('../read-chunk-from-disk', () => ({
+ writeChunkToDisk: vi.fn(),
+}));
+
+const downloadFileRangeMock = vi.mocked(downloadFileRange);
+const writeChunkToDiskMock = vi.mocked(writeChunkToDisk);
+
+const virtualFile = {
+ contentsId: 'contents-id',
+ name: 'video',
+ nameWithExtension: 'video.mp4',
+ type: 'mp4',
+ uuid: 'uuid',
+ size: 1024,
+} as unknown as File;
+
+function createState(): FileHydrationState {
+ return getOrCreateHydrationState(virtualFile.contentsId, virtualFile.size);
+}
+
+function createVirtualFile(overrides: Partial