feat: download and preview files from local backups automatically, if a local backup is available (#2076)
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { FileBackupRecord, FileBackupsDevice, FileBackupsMapping } from '@web/Application/Device/DesktopSnjsExports'
|
||||
import {
|
||||
FileBackupRecord,
|
||||
FileBackupsDevice,
|
||||
FileBackupsMapping,
|
||||
FileBackupReadToken,
|
||||
FileBackupReadChunkResponse,
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { AppState } from 'app/AppState'
|
||||
import { shell } from 'electron'
|
||||
import { StoreKeys } from '../Store/StoreKeys'
|
||||
@@ -6,13 +12,14 @@ import path from 'path'
|
||||
import {
|
||||
deleteFile,
|
||||
ensureDirectoryExists,
|
||||
moveFiles,
|
||||
moveDirContents,
|
||||
openDirectoryPicker,
|
||||
readJSONFile,
|
||||
writeFile,
|
||||
writeJSONFile,
|
||||
} from '../Utils/FileUtils'
|
||||
import { FileDownloader } from './FileDownloader'
|
||||
import { FileReadOperation } from './FileReadOperation'
|
||||
|
||||
export const FileBackupsConstantsV1 = {
|
||||
Version: '1.0.0',
|
||||
@@ -21,6 +28,8 @@ export const FileBackupsConstantsV1 = {
|
||||
}
|
||||
|
||||
export class FilesBackupManager implements FileBackupsDevice {
|
||||
private readOperations: Map<string, FileReadOperation> = new Map()
|
||||
|
||||
constructor(private appState: AppState) {}
|
||||
|
||||
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||
@@ -78,8 +87,11 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
}
|
||||
|
||||
const entries = Object.values(mapping.files)
|
||||
const itemFolders = entries.map((entry) => path.join(oldPath, entry.relativePath))
|
||||
await moveFiles(itemFolders, newPath)
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(oldPath, entry.relativePath)
|
||||
const destinationPath = path.join(newPath, entry.relativePath)
|
||||
await moveDirContents(sourcePath, destinationPath)
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
entry.absolutePath = path.join(newPath, entry.relativePath)
|
||||
@@ -188,4 +200,28 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getFileBackupReadToken(record: FileBackupRecord): Promise<FileBackupReadToken> {
|
||||
const operation = new FileReadOperation(record)
|
||||
|
||||
this.readOperations.set(operation.token, operation)
|
||||
|
||||
return operation.token
|
||||
}
|
||||
|
||||
async readNextChunk(token: string): Promise<FileBackupReadChunkResponse> {
|
||||
const operation = this.readOperations.get(token)
|
||||
|
||||
if (!operation) {
|
||||
return Promise.reject(new Error('Invalid token'))
|
||||
}
|
||||
|
||||
const result = await operation.readNextChunk()
|
||||
|
||||
if (result.isLast) {
|
||||
this.readOperations.delete(token)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FileBackupReadChunkResponse, FileBackupRecord } from '@web/Application/Device/DesktopSnjsExports'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const ONE_MB = 1024 * 1024
|
||||
const CHUNK_LIMIT = ONE_MB * 5
|
||||
|
||||
export class FileReadOperation {
|
||||
public readonly token: string
|
||||
private currentChunkLocation = 0
|
||||
private localFileId: number
|
||||
private fileLength: number
|
||||
|
||||
constructor(backupRecord: FileBackupRecord) {
|
||||
this.token = backupRecord.absolutePath
|
||||
this.localFileId = fs.openSync(path.join(backupRecord.absolutePath, backupRecord.binaryFileName), 'r')
|
||||
this.fileLength = fs.fstatSync(this.localFileId).size
|
||||
}
|
||||
|
||||
async readNextChunk(): Promise<FileBackupReadChunkResponse> {
|
||||
let isLast = false
|
||||
let readUpto = this.currentChunkLocation + CHUNK_LIMIT
|
||||
if (readUpto > this.fileLength) {
|
||||
readUpto = this.fileLength
|
||||
isLast = true
|
||||
}
|
||||
|
||||
const readLength = readUpto - this.currentChunkLocation
|
||||
|
||||
const chunk = await this.readChunk(this.currentChunkLocation, readLength)
|
||||
|
||||
this.currentChunkLocation = readUpto
|
||||
|
||||
if (isLast) {
|
||||
fs.close(this.localFileId)
|
||||
}
|
||||
|
||||
return {
|
||||
chunk,
|
||||
isLast,
|
||||
progress: {
|
||||
encryptedFileSize: this.fileLength,
|
||||
encryptedBytesDownloaded: this.currentChunkLocation,
|
||||
encryptedBytesRemaining: this.fileLength - this.currentChunkLocation,
|
||||
percentComplete: (this.currentChunkLocation / this.fileLength) * 100.0,
|
||||
source: 'local',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async readChunk(start: number, length: number): Promise<Uint8Array> {
|
||||
const buffer = Buffer.alloc(length)
|
||||
|
||||
fs.readSync(this.localFileId, buffer, 0, length, start)
|
||||
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ import { StoreKeys } from '../Store/StoreKeys'
|
||||
const path = require('path')
|
||||
const rendererPath = path.join('file://', __dirname, '/renderer.js')
|
||||
|
||||
import { FileBackupsDevice, FileBackupsMapping, FileBackupRecord } from '@web/Application/Device/DesktopSnjsExports'
|
||||
import {
|
||||
FileBackupsDevice,
|
||||
FileBackupsMapping,
|
||||
FileBackupRecord,
|
||||
FileBackupReadToken,
|
||||
FileBackupReadChunkResponse,
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { BackupsManagerInterface } from '../Backups/BackupsManagerInterface'
|
||||
import { KeychainInterface } from '../Keychain/KeychainInterface'
|
||||
@@ -65,6 +71,8 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
getFilesBackupsLocation: this.getFilesBackupsLocation.bind(this),
|
||||
openFilesBackupsLocation: this.openFilesBackupsLocation.bind(this),
|
||||
openFileBackup: this.openFileBackup.bind(this),
|
||||
getFileBackupReadToken: this.getFileBackupReadToken.bind(this),
|
||||
readNextChunk: this.readNextChunk.bind(this),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +188,14 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
return this.fileBackups.saveFilesBackupsFile(uuid, metaFile, downloadRequest)
|
||||
}
|
||||
|
||||
getFileBackupReadToken(record: FileBackupRecord): Promise<FileBackupReadToken> {
|
||||
return this.fileBackups.getFileBackupReadToken(record)
|
||||
}
|
||||
|
||||
readNextChunk(nextToken: string): Promise<FileBackupReadChunkResponse> {
|
||||
return this.fileBackups.readNextChunk(nextToken)
|
||||
}
|
||||
|
||||
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||
return this.fileBackups.isFilesBackupsEnabled()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { removeFromArray } from '../Utils/Utils'
|
||||
|
||||
export const FileDoesNotExist = 'ENOENT'
|
||||
export const FileAlreadyExists = 'EEXIST'
|
||||
const CrossDeviceLink = 'EXDEV'
|
||||
const OperationNotPermitted = 'EPERM'
|
||||
const DeviceIsBusy = 'EBUSY'
|
||||
|
||||
@@ -152,8 +151,14 @@ function isChildOfDir(parent: string, potentialChild: string) {
|
||||
return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
export async function moveDirContents(srcDir: string, destDir: string): Promise<void[]> {
|
||||
let fileNames = await fs.promises.readdir(srcDir)
|
||||
export async function moveDirContents(srcDir: string, destDir: string): Promise<void> {
|
||||
let fileNames: string[]
|
||||
try {
|
||||
fileNames = await fs.promises.readdir(srcDir)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
await ensureDirectoryExists(destDir)
|
||||
|
||||
if (isChildOfDir(srcDir, destDir)) {
|
||||
@@ -163,10 +168,14 @@ export async function moveDirContents(srcDir: string, destDir: string): Promise<
|
||||
removeFromArray(fileNames, path.basename(destDir))
|
||||
}
|
||||
|
||||
return moveFiles(
|
||||
fileNames.map((fileName) => path.join(srcDir, fileName)),
|
||||
destDir,
|
||||
)
|
||||
try {
|
||||
await moveFiles(
|
||||
fileNames.map((fileName) => path.join(srcDir, fileName)),
|
||||
destDir,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractZip(source: string, dest: string): Promise<void> {
|
||||
@@ -245,14 +254,10 @@ export async function moveFiles(sources: string[], destDir: string): Promise<voi
|
||||
async function moveFile(source: PathLike, destination: PathLike) {
|
||||
try {
|
||||
await fs.promises.rename(source, destination)
|
||||
} catch (error: any) {
|
||||
if (error.code === CrossDeviceLink) {
|
||||
/** Fall back to copying and then deleting. */
|
||||
await fs.promises.copyFile(source, destination)
|
||||
await fs.promises.unlink(source)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
} catch (_error) {
|
||||
/** Fall back to copying and then deleting. */
|
||||
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_FICLONE_FORCE)
|
||||
await fs.promises.unlink(source)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user