+
+
+
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..ec57078c4e 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,9 @@
"dropdown": {
"preferences": "Preferences",
"issues": "Issues",
- "send-feedback": "Send feedback",
"support": "Support",
"logout": "Log out",
"quit": "Quit",
- "antivirus": "Antivirus",
"cleaner": "Cleaner",
"new": "New",
"sync": "Sync"
@@ -146,22 +79,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 +94,6 @@
"failed": "Sync failed"
},
"errors": {
- "lock": "Sync locked by other device",
"offline": "Not connected to the internet"
}
},
@@ -182,9 +104,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 +154,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 +165,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 +187,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 +240,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 +280,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 +288,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 +302,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 +359,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 +384,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..f6cb17abfe 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,9 @@
"dropdown": {
"preferences": "Preferencias",
"issues": "Lista de errores",
- "send-feedback": "Enviar feedback",
"support": "Ayuda",
"logout": "Cerrar sesión",
"quit": "Salir",
- "antivirus": "Antivirus",
"cleaner": "Cleaner",
"new": "Nuevo",
"sync": "Sincronizar"
@@ -146,22 +79,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 +94,6 @@
"failed": "Sincronización fallida"
},
"errors": {
- "lock": "Sincronización bloqueada por otro dispositivo",
"offline": "No hay conexión a internet"
}
},
@@ -182,9 +104,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 +154,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 +165,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 +187,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 +240,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 +280,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 +288,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 +302,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 +326,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 +359,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 +384,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..5c26d57114 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,9 @@
"dropdown": {
"preferences": "Préférences",
"issues": "Liste d'erreurs",
- "send-feedback": "Envoyer des commentaires",
"support": "Aide",
"logout": "Déconnecter",
"quit": "Fermer",
- "antivirus": "Antivirus",
"cleaner": "Cleaner",
"new": "Nouveau",
"sync": "Synchroniser"
@@ -146,22 +79,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 +94,6 @@
"failed": "Échec de la synchronisation"
},
"errors": {
- "lock": "Synchronisation bloquée par un autre dispositif",
"offline": "Pas de connexion à internet"
}
},
@@ -182,9 +104,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 +154,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 +165,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 +187,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 +240,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 +280,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 +288,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 +302,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 +326,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 +359,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 +384,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.tsx b/src/apps/renderer/pages/Widget/ItemsSection.tsx
index 2a84b21dab..b2025a0ce6 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 (
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/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/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/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/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/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/cleaner/web-cache/utils/scan-firefox-cache-profiles.ts b/src/backend/features/cleaner/web-cache/utils/scan-firefox-cache-profiles.ts
index 061d5a56b0..91aa469cad 100644
--- a/src/backend/features/cleaner/web-cache/utils/scan-firefox-cache-profiles.ts
+++ b/src/backend/features/cleaner/web-cache/utils/scan-firefox-cache-profiles.ts
@@ -25,7 +25,7 @@ export async function scanFirefoxCacheProfiles(firefoxCacheDir: string): Promise
const profileDirsChecks = await Promise.allSettled(
entries.map(async (entry) => {
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 = {}): File {
+ return {
+ ...virtualFile,
+ ...overrides,
+ } as File;
+}
+
+function createProps(overrides: Partial[0]> = {}) {
+ return {
+ bucketId: 'bucket-id',
+ mnemonic: 'mnemonic',
+ network: {} as Parameters[0]['network'],
+ onDownloadProgress: vi.fn(),
+ virtualFile,
+ filePath: '/tmp/cache-file',
+ state: createState(),
+ blockStart: 100,
+ blockLength: 50,
+ ...overrides,
+ };
+}
+
+describe('downloadAndCacheBlock', () => {
+ beforeEach(() => {
+ clearHydrationState();
+ downloadFileRangeMock.mockResolvedValue({ data: Buffer.from('downloaded') });
+ writeChunkToDiskMock.mockResolvedValue(undefined);
+ });
+
+ it('downloads the requested range and writes it to the cache file offset', async () => {
+ const props = createProps();
+
+ await downloadAndCacheBlock(props);
+
+ expect(downloadFileRangeMock).toHaveBeenCalledWith({
+ fileId: virtualFile.contentsId,
+ bucketId: props.bucketId,
+ mnemonic: props.mnemonic,
+ network: props.network,
+ range: { position: props.blockStart, length: props.blockLength },
+ signal: props.state.abortController.signal,
+ });
+ expect(writeChunkToDiskMock).toHaveBeenCalledWith('/tmp/cache-file', Buffer.from('downloaded'), 100);
+ });
+
+ it('marks the block hydrated only after download and disk write succeed', async () => {
+ const props = createProps();
+
+ await downloadAndCacheBlock(props);
+
+ expect(isRangeHydrated(props.state, { position: props.blockStart, length: props.blockLength })).toBe(true);
+ });
+
+ it('emits progress from hydrated bytes after the block is written and marked hydrated', async () => {
+ const onDownloadProgress = vi.fn();
+ const hydratedFile = createVirtualFile({ contentsId: 'first-block-file', size: BLOCK_SIZE * 2 });
+ const state = getOrCreateHydrationState(hydratedFile.contentsId, hydratedFile.size);
+ state.stopwatch = { elapsedTime: vi.fn(() => 123) } as unknown as FileHydrationState['stopwatch'];
+
+ await downloadAndCacheBlock(
+ createProps({
+ state,
+ onDownloadProgress,
+ virtualFile: hydratedFile,
+ blockStart: 0,
+ blockLength: BLOCK_SIZE,
+ }),
+ );
+
+ expect(onDownloadProgress).toHaveBeenCalledWith('video', 'mp4', BLOCK_SIZE, hydratedFile.size, 123);
+ });
+
+ it('does not report full progress for a random EOF block when earlier blocks are missing', async () => {
+ const onDownloadProgress = vi.fn();
+ const eofFile = createVirtualFile({ contentsId: 'eof-file', size: BLOCK_SIZE * 3 + 123 });
+ const state = getOrCreateHydrationState(eofFile.contentsId, eofFile.size);
+
+ await downloadAndCacheBlock(
+ createProps({
+ state,
+ onDownloadProgress,
+ virtualFile: eofFile,
+ blockStart: BLOCK_SIZE * 3,
+ blockLength: 123,
+ }),
+ );
+
+ expect(onDownloadProgress).toHaveBeenCalledWith('video', 'mp4', 123, eofFile.size, 0);
+ });
+
+ it('counts the final block by its actual length', async () => {
+ const onDownloadProgress = vi.fn();
+ const fileWithPartialFinalBlock = createVirtualFile({
+ contentsId: 'partial-final-block-file',
+ size: BLOCK_SIZE + 123,
+ });
+ const state = getOrCreateHydrationState(fileWithPartialFinalBlock.contentsId, fileWithPartialFinalBlock.size);
+
+ await downloadAndCacheBlock(
+ createProps({
+ state,
+ onDownloadProgress,
+ virtualFile: fileWithPartialFinalBlock,
+ blockStart: BLOCK_SIZE,
+ blockLength: 123,
+ }),
+ );
+
+ expect(onDownloadProgress).toHaveBeenCalledWith('video', 'mp4', 123, fileWithPartialFinalBlock.size, 0);
+ });
+
+ it('reports 100% progress when every block is hydrated', async () => {
+ const onDownloadProgress = vi.fn();
+ const fullyHydratedFile = createVirtualFile({ contentsId: 'fully-hydrated-file', size: BLOCK_SIZE + 123 });
+ const state = getOrCreateHydrationState(fullyHydratedFile.contentsId, fullyHydratedFile.size);
+ markBlocksInRangeDownloaded(state, { position: 0, length: BLOCK_SIZE });
+
+ await downloadAndCacheBlock(
+ createProps({
+ state,
+ onDownloadProgress,
+ virtualFile: fullyHydratedFile,
+ blockStart: BLOCK_SIZE,
+ blockLength: 123,
+ }),
+ );
+
+ expect(onDownloadProgress).toHaveBeenCalledWith('video', 'mp4', fullyHydratedFile.size, fullyHydratedFile.size, 0);
+ });
+
+ it('does not write, mark hydrated, or emit progress when the range download fails', async () => {
+ const props = createProps();
+ downloadFileRangeMock.mockResolvedValue({ error: new Error('network failed') });
+
+ await expect(downloadAndCacheBlock(props)).resolves.toStrictEqual({ error: new Error('network failed') });
+
+ expect(writeChunkToDiskMock).not.toHaveBeenCalled();
+ expect(isRangeHydrated(props.state, { position: props.blockStart, length: props.blockLength })).toBe(false);
+ expect(props.onDownloadProgress).not.toHaveBeenCalled();
+ });
+
+ it('does not mark hydrated or emit progress when the disk write fails', async () => {
+ const props = createProps();
+ writeChunkToDiskMock.mockRejectedValue(new Error('write failed'));
+
+ await expect(downloadAndCacheBlock(props)).resolves.toStrictEqual({ error: new Error('write failed') });
+
+ expect(isRangeHydrated(props.state, { position: props.blockStart, length: props.blockLength })).toBe(false);
+ expect(props.onDownloadProgress).not.toHaveBeenCalled();
+ });
+
+ it('does not start a download when hydration is already aborted', async () => {
+ const props = createProps();
+ props.state.abortController.abort();
+
+ await expect(downloadAndCacheBlock(props)).resolves.toStrictEqual({ data: undefined });
+
+ expect(downloadFileRangeMock).not.toHaveBeenCalled();
+ expect(writeChunkToDiskMock).not.toHaveBeenCalled();
+ expect(isRangeHydrated(props.state, { position: props.blockStart, length: props.blockLength })).toBe(false);
+ expect(props.onDownloadProgress).not.toHaveBeenCalled();
+ });
+
+ it('does not write, mark hydrated, or emit progress when hydration aborts after download', async () => {
+ const props = createProps();
+ downloadFileRangeMock.mockImplementation(async () => {
+ props.state.abortController.abort();
+ return { data: Buffer.from('downloaded') };
+ });
+
+ await expect(downloadAndCacheBlock(props)).resolves.toStrictEqual({ data: undefined });
+
+ expect(writeChunkToDiskMock).not.toHaveBeenCalled();
+ expect(isRangeHydrated(props.state, { position: props.blockStart, length: props.blockLength })).toBe(false);
+ expect(props.onDownloadProgress).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/backend/features/fuse/on-read/download-cache/download-and-save-block.ts b/src/backend/features/fuse/on-read/download-cache/download-and-save-block.ts
new file mode 100644
index 0000000000..ccfccc5521
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/download-and-save-block.ts
@@ -0,0 +1,62 @@
+import { type HandleReadDeps } from '../types';
+import { writeChunkToDisk } from '../read-chunk-from-disk';
+import { getHydratedBytes, type FileHydrationState, markBlocksInRangeDownloaded } from './hydration-state';
+import { type File } from '../../../../../context/virtual-drive/files/domain/File';
+import { downloadFileRange } from '../../../../../infra/environment/download-file/download-file';
+import { type Result } from '../../../../../context/shared/domain/Result';
+type Props = {
+ bucketId: HandleReadDeps['bucketId'];
+ mnemonic: HandleReadDeps['mnemonic'];
+ network: HandleReadDeps['network'];
+ onDownloadProgress: HandleReadDeps['onDownloadProgress'];
+ virtualFile: File;
+ filePath: string;
+ state: FileHydrationState;
+ blockStart: number;
+ blockLength: number;
+};
+
+/**
+ * Downloads a block range, writes it to disk at the correct offset, and marks it as downloaded.
+ */
+export async function downloadAndCacheBlock({
+ bucketId,
+ mnemonic,
+ network,
+ onDownloadProgress,
+ virtualFile,
+ filePath,
+ state,
+ blockStart,
+ blockLength,
+}: Props): Promise> {
+ if (isAborted(state)) return { data: undefined };
+
+ try {
+ const download = await downloadFileRange({
+ fileId: virtualFile.contentsId,
+ bucketId,
+ mnemonic,
+ network,
+ range: { position: blockStart, length: blockLength },
+ signal: state.abortController.signal,
+ });
+ if (isAborted(state)) return { data: undefined };
+ if (download.error) return { error: download.error };
+
+ await writeChunkToDisk(filePath, download.data, blockStart);
+ if (isAborted(state)) return { data: undefined };
+
+ markBlocksInRangeDownloaded(state, { position: blockStart, length: blockLength });
+ const elapsedTime = state.stopwatch?.elapsedTime() ?? 0;
+ onDownloadProgress(virtualFile.name, virtualFile.type, getHydratedBytes(state), virtualFile.size, elapsedTime);
+ return { data: undefined };
+ } catch (error) {
+ if (isAborted(state)) return { data: undefined };
+ return { error: error instanceof Error ? error : new Error('Unknown error occurred') };
+ }
+}
+
+function isAborted(state: FileHydrationState): boolean {
+ return state.abortController.signal.aborted;
+}
diff --git a/src/backend/features/fuse/on-read/download-cache/expand-to-block-boundaries.test.ts b/src/backend/features/fuse/on-read/download-cache/expand-to-block-boundaries.test.ts
new file mode 100644
index 0000000000..8c7e5f9600
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/expand-to-block-boundaries.test.ts
@@ -0,0 +1,43 @@
+import { BLOCK_SIZE } from './constants';
+import { expandToBlockBoundaries } from './expand-to-block-boundaries';
+
+describe('expandToBlockBoundaries', () => {
+ it('expands a small read inside the first block to the full first block', () => {
+ const result = expandToBlockBoundaries({
+ range: { position: 100, length: 4096 },
+ fileSize: BLOCK_SIZE * 3,
+ });
+
+ expect(result).toStrictEqual({ blockStart: 0, blockLength: BLOCK_SIZE });
+ });
+
+ it('starts at the containing block boundary for reads after the first block', () => {
+ const result = expandToBlockBoundaries({
+ range: { position: BLOCK_SIZE + 100, length: 4096 },
+ fileSize: BLOCK_SIZE * 3,
+ });
+
+ expect(result).toStrictEqual({ blockStart: BLOCK_SIZE, blockLength: BLOCK_SIZE });
+ });
+
+ it('expands reads crossing a block boundary to cover every touched block', () => {
+ const result = expandToBlockBoundaries({
+ range: { position: BLOCK_SIZE - 100, length: 200 },
+ fileSize: BLOCK_SIZE * 3,
+ });
+
+ expect(result).toStrictEqual({ blockStart: 0, blockLength: BLOCK_SIZE * 2 });
+ });
+
+ it('expands a read inside a partial last block to that whole partial block', () => {
+ const partialLastBlockLength = 500;
+ const fileSize = BLOCK_SIZE + partialLastBlockLength;
+
+ const result = expandToBlockBoundaries({
+ range: { position: BLOCK_SIZE + 100, length: 100 },
+ fileSize,
+ });
+
+ expect(result).toStrictEqual({ blockStart: BLOCK_SIZE, blockLength: partialLastBlockLength });
+ });
+});
diff --git a/src/backend/features/fuse/on-read/download-cache/expand-to-block-boundaries.ts b/src/backend/features/fuse/on-read/download-cache/expand-to-block-boundaries.ts
new file mode 100644
index 0000000000..ebfc009bd5
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/expand-to-block-boundaries.ts
@@ -0,0 +1,17 @@
+import { ReadRange } from '../types';
+import { BLOCK_SIZE } from './constants';
+
+/**
+ * Given a position and length, rounds up to 4MB block boundaries so that every
+ * request downloads complete blocks. Ensuring correct bitmap tracking, prefetching,
+ * and preventing double downloads.
+ */
+export function expandToBlockBoundaries({ range, fileSize }: { range: ReadRange; fileSize: number }): {
+ blockStart: number;
+ blockLength: number;
+} {
+ const blockStart = Math.floor(range.position / BLOCK_SIZE) * BLOCK_SIZE;
+ const end = range.position + range.length;
+ const blockEnd = Math.min(Math.ceil(end / BLOCK_SIZE) * BLOCK_SIZE, fileSize);
+ return { blockStart, blockLength: blockEnd - blockStart };
+}
diff --git a/src/backend/features/fuse/on-read/download-cache/file-exists-on-disk.test.ts b/src/backend/features/fuse/on-read/download-cache/file-exists-on-disk.test.ts
new file mode 100644
index 0000000000..2b5318f6f1
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/file-exists-on-disk.test.ts
@@ -0,0 +1,26 @@
+import fs from 'node:fs/promises';
+import { fileExistsOnDisk } from './file-exists-on-disk';
+
+vi.mock('node:fs/promises', () => ({
+ default: {
+ stat: vi.fn(),
+ },
+}));
+
+const fsMock = vi.mocked(fs);
+
+describe('fileExistsOnDisk', () => {
+ it('returns true when fs.stat succeeds', async () => {
+ fsMock.stat.mockResolvedValue({} as Awaited>);
+
+ await expect(fileExistsOnDisk('/tmp/cache-file')).resolves.toBe(true);
+
+ expect(fsMock.stat).toHaveBeenCalledWith('/tmp/cache-file');
+ });
+
+ it('returns false when fs.stat rejects', async () => {
+ fsMock.stat.mockRejectedValue(new Error('missing'));
+
+ await expect(fileExistsOnDisk('/tmp/cache-file')).resolves.toBe(false);
+ });
+});
diff --git a/src/backend/features/fuse/on-read/download-cache/file-exists-on-disk.ts b/src/backend/features/fuse/on-read/download-cache/file-exists-on-disk.ts
new file mode 100644
index 0000000000..a8237fbd3a
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/file-exists-on-disk.ts
@@ -0,0 +1,7 @@
+import fs from 'node:fs/promises';
+export async function fileExistsOnDisk(filePath: string): Promise {
+ return fs
+ .stat(filePath)
+ .then(() => true)
+ .catch(() => false);
+}
diff --git a/src/backend/features/fuse/on-read/download-cache/hydration-state.test.ts b/src/backend/features/fuse/on-read/download-cache/hydration-state.test.ts
new file mode 100644
index 0000000000..255461e2ba
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/hydration-state.test.ts
@@ -0,0 +1,233 @@
+import {
+ abortAllHydrations,
+ abortHydrationState,
+ clearHydrationState,
+ ensureAllocatedOnce,
+ finalizeIfNeeded,
+ getExistingHydrationState,
+ getHydratedBytes,
+ getOrCreateHydrationState,
+ isFileHydrated,
+ markBlocksInRangeDownloaded,
+ markFinalized,
+} from './hydration-state';
+import { allocateFile } from './allocate-file';
+import { BLOCK_SIZE } from './constants';
+
+vi.mock('./allocate-file', () => ({
+ allocateFile: vi.fn(),
+}));
+
+const allocateFileMock = vi.mocked(allocateFile);
+
+describe('hydration-state lifecycle', () => {
+ beforeEach(() => {
+ clearHydrationState();
+ });
+
+ it('reads an existing state without creating a new one', () => {
+ const created = getOrCreateHydrationState('contents-id', 1024);
+
+ const existing = getExistingHydrationState('contents-id');
+
+ expect(existing).toBe(created);
+ });
+
+ it('does not create state when reading a missing contents id', () => {
+ const missing = getExistingHydrationState('missing');
+
+ expect(missing).toBeUndefined();
+ expect(getExistingHydrationState('missing')).toBeUndefined();
+ });
+
+ it('creates state once per contents id', () => {
+ const first = getOrCreateHydrationState('contents-id', 1024);
+ const second = getOrCreateHydrationState('contents-id', 2048);
+
+ expect(second).toBe(first);
+ });
+
+ it('creates new states with a fresh AbortController and unfinished finalization state', () => {
+ const first = getOrCreateHydrationState('first', 1024);
+ const second = getOrCreateHydrationState('second', 1024);
+
+ expect(first.abortController).toBeInstanceOf(AbortController);
+ expect(second.abortController).toBeInstanceOf(AbortController);
+ expect(first.abortController).not.toBe(second.abortController);
+ expect(first.fileSize).toBe(1024);
+ expect(first.hydratedBytes).toBe(0);
+ expect(first.finalized).toBe(false);
+ expect(first.finalization).toBeUndefined();
+ });
+
+ it('aborts one hydration state without aborting another', () => {
+ const first = getOrCreateHydrationState('first', 1024);
+ const second = getOrCreateHydrationState('second', 1024);
+
+ abortHydrationState(first);
+
+ expect(first.abortController.signal.aborted).toBe(true);
+ expect(second.abortController.signal.aborted).toBe(false);
+ });
+
+ it('aborts every hydration state', () => {
+ const first = getOrCreateHydrationState('first', 1024);
+ const second = getOrCreateHydrationState('second', 1024);
+
+ expect(first.abortController.signal.aborted).toBe(false);
+ expect(second.abortController.signal.aborted).toBe(false);
+ abortAllHydrations();
+
+ expect(first.abortController.signal.aborted).toBe(true);
+ expect(second.abortController.signal.aborted).toBe(true);
+ });
+
+ it('reuses the in-flight repository registration for concurrent finalization attempts', () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+ const promise = new Promise(() => undefined);
+
+ const first = finalizeIfNeeded(state, () => promise);
+ const second = finalizeIfNeeded(state, () => Promise.resolve());
+
+ expect(second).toBe(first);
+ expect(state.finalization).toBe(first);
+ expect(state.finalized).toBe(false);
+ });
+
+ it('allows failed finalization to be retried', async () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+
+ await expect(finalizeIfNeeded(state, () => Promise.reject(new Error('register failed')))).rejects.toThrow(
+ 'register failed',
+ );
+
+ expect(state.finalization).toBeUndefined();
+ expect(state.finalized).toBe(false);
+
+ await finalizeIfNeeded(state, () => Promise.resolve());
+
+ expect(state.finalized).toBe(true);
+ });
+
+ it('marks successful finalization as finalized', async () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+
+ await finalizeIfNeeded(state, () => Promise.resolve());
+
+ expect(state.finalized).toBe(true);
+ expect(state.finalization).toBeUndefined();
+ });
+
+ it('can mark a state as finalized directly', () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+
+ markFinalized(state);
+
+ expect(state.finalized).toBe(true);
+ });
+
+ it('reports hydrated bytes from completed blocks only', () => {
+ const fileSize = BLOCK_SIZE * 2 + 123;
+ const state = getOrCreateHydrationState('contents-id', fileSize);
+
+ markBlocksInRangeDownloaded(state, { position: 0, length: BLOCK_SIZE });
+
+ expect(getHydratedBytes(state)).toBe(BLOCK_SIZE);
+ });
+
+ it('counts the final block by its actual byte length', () => {
+ const fileSize = BLOCK_SIZE * 2 + 123;
+ const state = getOrCreateHydrationState('contents-id', fileSize);
+
+ markBlocksInRangeDownloaded(state, { position: BLOCK_SIZE * 2, length: 123 });
+
+ expect(getHydratedBytes(state)).toBe(123);
+ });
+
+ it('reports full file size when every block is hydrated', () => {
+ const fileSize = BLOCK_SIZE + 123;
+ const state = getOrCreateHydrationState('contents-id', fileSize);
+
+ markBlocksInRangeDownloaded(state, { position: 0, length: BLOCK_SIZE });
+ markBlocksInRangeDownloaded(state, { position: BLOCK_SIZE, length: 123 });
+
+ expect(getHydratedBytes(state)).toBe(fileSize);
+ });
+
+ it('counts hydrated bytes only once when the same block is marked again', () => {
+ const fileSize = BLOCK_SIZE + 123;
+ const state = getOrCreateHydrationState('contents-id', fileSize);
+
+ markBlocksInRangeDownloaded(state, { position: 0, length: BLOCK_SIZE });
+ markBlocksInRangeDownloaded(state, { position: 10, length: 10 });
+
+ expect(getHydratedBytes(state)).toBe(BLOCK_SIZE);
+ });
+
+ it('treats an empty file as fully hydrated without marking any blocks', () => {
+ const state = getOrCreateHydrationState('empty-contents-id', 0);
+
+ expect(isFileHydrated(state)).toBe(true);
+ expect(getHydratedBytes(state)).toBe(0);
+ });
+
+ describe('file allocation', () => {
+ it('allocates a file only once for concurrent callers', async () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+ let resolveAllocation: () => void = () => undefined;
+ allocateFileMock.mockReturnValue(
+ new Promise((resolve) => {
+ resolveAllocation = resolve;
+ }),
+ );
+
+ const first = ensureAllocatedOnce(state, '/tmp/cache-file', 1024);
+ const second = ensureAllocatedOnce(state, '/tmp/cache-file', 1024);
+
+ expect(first).toBe(second);
+ expect(allocateFileMock).toHaveBeenCalledOnce();
+ expect(allocateFileMock).toHaveBeenCalledWith('/tmp/cache-file', 1024);
+
+ resolveAllocation();
+ await expect(first).resolves.toStrictEqual({ data: undefined });
+ await expect(second).resolves.toStrictEqual({ data: undefined });
+ });
+
+ it('keeps successful allocation in state so later callers reuse it', async () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+ allocateFileMock.mockResolvedValue(undefined);
+
+ const first = ensureAllocatedOnce(state, '/tmp/cache-file', 1024);
+ await expect(first).resolves.toStrictEqual({ data: undefined });
+ const second = ensureAllocatedOnce(state, '/tmp/cache-file', 1024);
+
+ expect(second).toBe(first);
+ expect(allocateFileMock).toHaveBeenCalledOnce();
+ });
+
+ it('allows failed allocation to be retried', async () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+ allocateFileMock.mockRejectedValueOnce(new Error('allocation failed')).mockResolvedValueOnce(undefined);
+
+ await expect(ensureAllocatedOnce(state, '/tmp/cache-file', 1024)).resolves.toStrictEqual({
+ error: new Error('allocation failed'),
+ });
+
+ expect(state.allocation).toBeUndefined();
+
+ await expect(ensureAllocatedOnce(state, '/tmp/cache-file', 1024)).resolves.toStrictEqual({ data: undefined });
+
+ expect(allocateFileMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('starts the state stopwatch when allocation begins', async () => {
+ const state = getOrCreateHydrationState('contents-id', 1024);
+ allocateFileMock.mockResolvedValue(undefined);
+
+ await expect(ensureAllocatedOnce(state, '/tmp/cache-file', 1024)).resolves.toStrictEqual({ data: undefined });
+
+ expect(state.stopwatch).toBeDefined();
+ expect(state.stopwatch?.elapsedTime()).not.toBe(-1);
+ });
+ });
+});
diff --git a/src/backend/features/fuse/on-read/download-cache/hydration-state.ts b/src/backend/features/fuse/on-read/download-cache/hydration-state.ts
new file mode 100644
index 0000000000..51dc4e9281
--- /dev/null
+++ b/src/backend/features/fuse/on-read/download-cache/hydration-state.ts
@@ -0,0 +1,245 @@
+import { BITS_PER_BYTE, BLOCK_SIZE } from './constants';
+import { allocateFile } from './allocate-file';
+import { Stopwatch } from '../../../../../apps/shared/types/Stopwatch';
+import { type Result } from '../../../../../context/shared/domain/Result';
+import { ReadRange } from '../types';
+
+/**
+ * Tracks which byte ranges of a file have been downloaded and written to disk.
+ *
+ * Uses a bitmap where each bit represents one 4MB block of the file.
+ * A set bit means that block has been FULLY downloaded and written to disk.
+ * An unset bit means that block contains pre-allocation zeros — not real data.
+ *
+ * This is necessary because files are pre-allocated to their full size before any
+ * data is downloaded, making it impossible to distinguish real bytes from zeros
+ * by inspecting the file alone.
+ *
+ * A block is only marked after its full write to disk succeeds — never partially.
+ * A hard kill mid-write is handled by wiping the download cache on startup.
+ *
+ * Concurrent reads for the same block share the in-flight block download promise
+ * instead of starting duplicate downloads.
+ */
+
+export type FileHydrationState = {
+ bitmap: Buffer;
+ fileSize: number;
+ totalBlocks: number;
+ hydratedBytes: number;
+ blocksBeingDownloaded: Map>>;
+ allocation?: Promise>;
+ stopwatch?: Stopwatch;
+ finalized: boolean;
+ finalization?: Promise;
+ abortController: AbortController;
+};
+
+const hydrationState = new Map();
+
+export function getExistingHydrationState(contentsId: string): FileHydrationState | undefined {
+ return hydrationState.get(contentsId);
+}
+
+export function getOrCreateHydrationState(contentsId: string, fileSize: number): FileHydrationState {
+ const existing = getExistingHydrationState(contentsId);
+ if (existing) return existing;
+
+ const totalBlocks = Math.ceil(fileSize / BLOCK_SIZE);
+ const size = Math.ceil(totalBlocks / BITS_PER_BYTE);
+ const state: FileHydrationState = {
+ bitmap: Buffer.alloc(size, 0),
+ fileSize,
+ totalBlocks,
+ hydratedBytes: 0,
+ blocksBeingDownloaded: new Map(),
+ finalized: false,
+ abortController: new AbortController(),
+ };
+ hydrationState.set(contentsId, state);
+ return state;
+}
+
+export function ensureAllocatedOnce(
+ state: FileHydrationState,
+ filePath: string,
+ fileSize: number,
+): Promise> {
+ if (state.allocation) return state.allocation;
+
+ state.stopwatch = new Stopwatch();
+ state.stopwatch.start();
+
+ const allocation = allocateFile(filePath, fileSize).then(
+ (): Result => ({ data: undefined }),
+ (error): Result => {
+ if (state.allocation === allocation) {
+ state.allocation = undefined;
+ state.stopwatch = undefined;
+ }
+ return { error: error instanceof Error ? error : new Error('Unknown error occurred') };
+ },
+ );
+
+ state.allocation = allocation;
+ return allocation;
+}
+
+function blockIndexForByte(byte: number): number {
+ return Math.floor(byte / BLOCK_SIZE);
+}
+
+/**
+ * Creates a bitmask: a number where exactly ONE bit is turned on.
+ *
+ * Think of a byte as 8 switches:
+ * [bit7][bit6][bit5][bit4][bit3][bit2][bit1][bit0]
+ *
+ * The mask selects exactly one of those switches.
+ *
+ * Examples:
+ * bitIndexInByte = 0 is 0b00000001 (selects bit 0)
+ * bitIndexInByte = 2 is 0b00000100 (selects bit 2)
+ * bitIndexInByte = 7 is 0b10000000 (selects bit 7)
+ *
+ * Why we need this:
+ * - AND (&) with the mask → checks if that bit is set
+ * - OR (|) with the mask → sets that bit
+ *
+ * Implementation:
+ * Start with 1 (0b00000001) and shift it left N times.
+ */
+function bitMask(bitIndexInByte: number): number {
+ return 1 << bitIndexInByte;
+}
+
+function getBit(bitmap: Buffer, blockIndex: number): boolean {
+ const byteIndex = Math.floor(blockIndex / BITS_PER_BYTE);
+ const bitIndexInByte = blockIndex % BITS_PER_BYTE;
+ return (bitmap[byteIndex] & bitMask(bitIndexInByte)) !== 0;
+}
+
+function setBit(bitmap: Buffer, blockIndex: number): void {
+ const byteIndex = Math.floor(blockIndex / BITS_PER_BYTE);
+ const bitIndexInByte = blockIndex % BITS_PER_BYTE;
+ bitmap[byteIndex] = bitmap[byteIndex] | bitMask(bitIndexInByte);
+}
+
+export function isFileHydrated(state: FileHydrationState): boolean {
+ return state.hydratedBytes === state.fileSize;
+}
+
+export function getHydratedBytes(state: FileHydrationState): number {
+ return state.hydratedBytes;
+}
+
+function blocksWithinRange({ position, length }: ReadRange): Array {
+ const first = blockIndexForByte(position);
+ const last = blockIndexForByte(position + length - 1);
+ const blocks: number[] = [];
+ for (let block = first; block <= last; block++) {
+ blocks.push(block);
+ }
+ return blocks;
+}
+
+export function isRangeHydrated(state: FileHydrationState, { position, length }: ReadRange): boolean {
+ return blocksWithinRange({ position, length }).every((block) => getBit(state.bitmap, block));
+}
+
+export function markBlocksInRangeDownloaded(state: FileHydrationState, { position, length }: ReadRange): void {
+ for (const block of blocksWithinRange({ position, length })) {
+ if (!getBit(state.bitmap, block)) {
+ setBit(state.bitmap, block);
+ state.hydratedBytes += blockByteLength(state, block);
+ }
+ }
+}
+
+function blockByteLength(state: FileHydrationState, block: number): number {
+ const blockStart = block * BLOCK_SIZE;
+ return Math.min(BLOCK_SIZE, state.fileSize - blockStart);
+}
+
+/**
+ * Returns block indices within the range that are neither hydrated nor already downloading.
+ * Call after waiting for existing in-flight blocks to identify the remaining work.
+ */
+export function getMissingBlocks(state: FileHydrationState, { position, length }: ReadRange): number[] {
+ return blocksWithinRange({ position, length }).filter(
+ (block) => !getBit(state.bitmap, block) && !state.blocksBeingDownloaded.has(block),
+ );
+}
+
+export function getBlocksBeingDownloaded(
+ state: FileHydrationState,
+ { position, length }: ReadRange,
+): Map>> {
+ const blocksBeingDownloadedWithinRange = new Map>>();
+ for (const block of blocksWithinRange({ position, length })) {
+ const existing = state.blocksBeingDownloaded.get(block);
+ if (existing) blocksBeingDownloadedWithinRange.set(block, existing);
+ }
+ return blocksBeingDownloadedWithinRange;
+}
+
+export function setBlockDownloadInFlight(
+ state: FileHydrationState,
+ block: number,
+ promise: Promise>,
+): void {
+ state.blocksBeingDownloaded.set(block, promise);
+}
+
+export function clearBlockDownloadInFlight(
+ state: FileHydrationState,
+ block: number,
+ promise: Promise>,
+): void {
+ if (state.blocksBeingDownloaded.get(block) === promise) {
+ state.blocksBeingDownloaded.delete(block);
+ }
+}
+
+export function finalizeIfNeeded(state: FileHydrationState, finalize: () => Promise): Promise