feat: download and preview files from local backups automatically, if a local backup is available (#2076)

This commit is contained in:
Mo
2022-12-01 11:56:28 -06:00
committed by GitHub
parent e07fed267f
commit 28e43d37c0
34 changed files with 739 additions and 110 deletions

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -4,6 +4,8 @@ import {
FileBackupsMapping,
RawKeychainValue,
FileBackupRecord,
FileBackupReadToken,
FileBackupReadChunkResponse,
} from '@web/Application/Device/DesktopSnjsExports'
import { WebOrDesktopDevice } from '@web/Application/Device/WebOrDesktopDevice'
import { Component } from '../Main/Packages/PackageManagerInterface'
@@ -149,6 +151,14 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn
return this.remoteBridge.saveFilesBackupsFile(uuid, metaFile, downloadRequest)
}
getFileBackupReadToken(record: FileBackupRecord): Promise<FileBackupReadToken> {
return this.remoteBridge.getFileBackupReadToken(record)
}
readNextChunk(token: string): Promise<FileBackupReadChunkResponse> {
return this.remoteBridge.readNextChunk(token)
}
async performHardReset(): Promise<void> {
console.error('performHardReset is not yet implemented')
}

View File

@@ -1,4 +1,4 @@
import { ByteChunker, OnChunkCallback } from '@standardnotes/files'
import { ByteChunker, OnChunkCallbackNoProgress } from '@standardnotes/files'
import { FileSelectionResponse } from '../types'
import { readFile as utilsReadFile } from '../utils'
import { FileReaderInterface } from '../Interface/FileReader'
@@ -39,7 +39,7 @@ function selectFiles(): Promise<File[]> {
async function readFile(
file: File,
minimumChunkSize: number,
onChunk: OnChunkCallback,
onChunk: OnChunkCallbackNoProgress,
): Promise<FileSelectionResponse> {
const buffer = await utilsReadFile(file)
const chunker = new ByteChunker(minimumChunkSize, onChunk)

View File

@@ -1,11 +1,11 @@
import { OnChunkCallback } from '@standardnotes/files'
import { OnChunkCallbackNoProgress } from '@standardnotes/files'
import { FileSelectionResponse } from '../types'
export interface FileReaderInterface {
selectFiles(): Promise<File[]>
readFile(file: File, minimumChunkSize: number, onChunk: OnChunkCallback): Promise<FileSelectionResponse>
readFile(file: File, minimumChunkSize: number, onChunk: OnChunkCallbackNoProgress): Promise<FileSelectionResponse>
available(): boolean

View File

@@ -1,4 +1,4 @@
import { ByteChunker, OnChunkCallback } from '@standardnotes/files'
import { ByteChunker, OnChunkCallbackNoProgress } from '@standardnotes/files'
import { FileReaderInterface } from './../Interface/FileReader'
import { FileSelectionResponse } from '../types'
@@ -39,7 +39,7 @@ async function selectFiles(): Promise<File[]> {
async function readFile(
file: File,
minimumChunkSize: number,
onChunk: OnChunkCallback,
onChunk: OnChunkCallbackNoProgress,
): Promise<FileSelectionResponse> {
const byteChunker = new ByteChunker(minimumChunkSize, onChunk)
const stream = file.stream() as unknown as ReadableStream

View File

@@ -8,9 +8,9 @@ describe('byte chunker', () => {
it('should hold back small chunks until minimum size is met', async () => {
let receivedBytes = new Uint8Array()
let numChunks = 0
const chunker = new ByteChunker(100, async (bytes) => {
const chunker = new ByteChunker(100, async (chunk) => {
numChunks++
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
})
await chunker.addBytes(chunkOfSize(50), false)
@@ -25,9 +25,9 @@ describe('byte chunker', () => {
it('should send back big chunks immediately', async () => {
let receivedBytes = new Uint8Array()
let numChunks = 0
const chunker = new ByteChunker(100, async (bytes) => {
const chunker = new ByteChunker(100, async (chunk) => {
numChunks++
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
})
await chunker.addBytes(chunkOfSize(150), false)
@@ -42,9 +42,9 @@ describe('byte chunker', () => {
it('last chunk should be popped regardless of size', async () => {
let receivedBytes = new Uint8Array()
let numChunks = 0
const chunker = new ByteChunker(100, async (bytes) => {
const chunker = new ByteChunker(100, async (chunk) => {
numChunks++
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
})
await chunker.addBytes(chunkOfSize(50), false)
@@ -57,9 +57,9 @@ describe('byte chunker', () => {
it('single chunk should be popped immediately', async () => {
let receivedBytes = new Uint8Array()
let numChunks = 0
const chunker = new ByteChunker(100, async (bytes) => {
const chunker = new ByteChunker(100, async (chunk) => {
numChunks++
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
})
await chunker.addBytes(chunkOfSize(50), true)

View File

@@ -1,11 +1,11 @@
import { OnChunkCallback } from './OnChunkCallback'
import { OnChunkCallbackNoProgress } from './OnChunkCallback'
export class ByteChunker {
public loggingEnabled = false
private bytes = new Uint8Array()
private index = 1
constructor(private minimumChunkSize: number, private onChunk: OnChunkCallback) {}
constructor(private minimumChunkSize: number, private onChunk: OnChunkCallbackNoProgress) {}
private log(...args: any[]): void {
if (!this.loggingEnabled) {
@@ -27,9 +27,13 @@ export class ByteChunker {
private async popBytes(isLast: boolean): Promise<void> {
const maxIndex = Math.max(this.minimumChunkSize, this.bytes.length)
const chunk = this.bytes.slice(0, maxIndex)
this.bytes = new Uint8Array([...this.bytes.slice(maxIndex)])
this.log(`Chunker popping ${chunk.length}, total size in queue ${this.bytes.length}`)
await this.onChunk(chunk, this.index++, isLast)
await this.onChunk({ data: chunk, index: this.index++, isLast })
}
}

View File

@@ -1 +1,10 @@
export type OnChunkCallback = (chunk: Uint8Array, index: number, isLast: boolean) => Promise<void>
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
export type OnChunkCallback = (chunk: {
data: Uint8Array
index: number
isLast: boolean
progress: FileDownloadProgress
}) => Promise<void>
export type OnChunkCallbackNoProgress = (chunk: { data: Uint8Array; index: number; isLast: boolean }) => Promise<void>

View File

@@ -10,9 +10,30 @@ describe('ordered byte chunker', () => {
let receivedBytes = new Uint8Array()
let numCallbacks = 0
const chunker = new OrderedByteChunker(chunkSizes, async (bytes) => {
const chunker = new OrderedByteChunker(chunkSizes, 'network', async (chunk) => {
numCallbacks++
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
})
await chunker.addBytes(chunkOfSize(30))
expect(numCallbacks).toEqual(3)
expect(receivedBytes.length).toEqual(30)
})
it('should correctly report progress', async () => {
const chunkSizes = [10, 10, 10]
let receivedBytes = new Uint8Array()
let numCallbacks = 0
const chunker = new OrderedByteChunker(chunkSizes, 'network', async (chunk) => {
numCallbacks++
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
expect(chunk.progress.encryptedBytesDownloaded).toEqual(receivedBytes.length)
expect(chunk.progress.percentComplete).toEqual((numCallbacks / chunkSizes.length) * 100.0)
})
await chunker.addBytes(chunkOfSize(30))

View File

@@ -1,13 +1,28 @@
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
import { OnChunkCallback } from './OnChunkCallback'
export class OrderedByteChunker {
private bytes = new Uint8Array()
private index = 1
private remainingChunks: number[] = []
private fileSize: number
constructor(
private chunkSizes: number[],
private onChunk: (chunk: Uint8Array, index: number, isLast: boolean) => Promise<void>,
private source: FileDownloadProgress['source'],
private onChunk: OnChunkCallback,
) {
this.remainingChunks = chunkSizes.slice()
this.fileSize = chunkSizes.reduce((acc, size) => acc + size, 0)
}
private get bytesPopped(): number {
return this.fileSize - this.bytesRemaining
}
private get bytesRemaining(): number {
return this.remainingChunks.reduce((acc, size) => acc + size, 0)
}
private needsPop(): boolean {
@@ -31,7 +46,18 @@ export class OrderedByteChunker {
this.remainingChunks.shift()
await this.onChunk(chunk, this.index++, this.index === this.chunkSizes.length - 1)
await this.onChunk({
data: chunk,
index: this.index++,
isLast: this.index === this.chunkSizes.length - 1,
progress: {
encryptedFileSize: this.fileSize,
encryptedBytesDownloaded: this.bytesPopped,
encryptedBytesRemaining: this.bytesRemaining,
percentComplete: (this.bytesPopped / this.fileSize) * 100.0,
source: this.source,
},
})
if (this.needsPop()) {
await this.popBytes()

View File

@@ -1,6 +1,10 @@
import { Uuid } from '@standardnotes/common'
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
import { FileBackupRecord, FileBackupsMapping } from './FileBackupsMapping'
export type FileBackupReadToken = string
export type FileBackupReadChunkResponse = { chunk: Uint8Array; isLast: boolean; progress: FileDownloadProgress }
export interface FileBackupsDevice {
getFilesBackupsMappingFile(): Promise<FileBackupsMapping>
saveFilesBackupsFile(
@@ -12,6 +16,8 @@ export interface FileBackupsDevice {
url: string
},
): Promise<'success' | 'failed'>
getFileBackupReadToken(record: FileBackupRecord): Promise<FileBackupReadToken>
readNextChunk(token: string): Promise<FileBackupReadChunkResponse>
isFilesBackupsEnabled(): Promise<boolean>
enableFilesBackups(): Promise<void>
disableFilesBackups(): Promise<void>

View File

@@ -0,0 +1,30 @@
import { logWithColor } from '@standardnotes/utils'
declare const process: {
env: {
NODE_ENV: string | null | undefined
}
}
export const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
export enum LoggingDomain {
FilesPackage,
}
const LoggingStatus: Record<LoggingDomain, boolean> = {
[LoggingDomain.FilesPackage]: false,
}
const DomainColor: Record<LoggingDomain, string> = {
[LoggingDomain.FilesPackage]: 'green',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function log(domain: LoggingDomain, ...args: any[]): void {
if (!isDev || !LoggingStatus[domain]) {
return
}
logWithColor(LoggingDomain[domain], DomainColor[domain], ...args)
}

View File

@@ -0,0 +1,14 @@
import { OnChunkCallback } from '../Chunker/OnChunkCallback'
import { FileBackupRecord } from '../Device/FileBackupsMapping'
export interface BackupServiceInterface {
getFileBackupInfo(file: { uuid: string }): Promise<FileBackupRecord | undefined>
readEncryptedFileFromBackup(uuid: string, onChunk: OnChunkCallback): Promise<'success' | 'failed' | 'aborted'>
isFilesBackupsEnabled(): Promise<boolean>
enableFilesBackups(): Promise<void>
disableFilesBackups(): Promise<void>
changeFilesBackupsLocation(): Promise<string | undefined>
getFilesBackupsLocation(): Promise<string>
openFilesBackupsLocation(): Promise<void>
openFileBackup(record: FileBackupRecord): Promise<void>
}

View File

@@ -24,7 +24,7 @@ export interface FilesClientInterface {
downloadFile(
file: FileItem,
onDecryptedBytes: (bytes: Uint8Array, progress: FileDownloadProgress | undefined) => Promise<void>,
onDecryptedBytes: (bytes: Uint8Array, progress: FileDownloadProgress) => Promise<void>,
): Promise<ClientDisplayableError | undefined>
deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined>

View File

@@ -5,7 +5,7 @@ import { FileSystemApi } from '../Api/FileSystemApi'
import { FileHandleRead } from '../Api/FileHandleRead'
import { OrderedByteChunker } from '../Chunker/OrderedByteChunker'
export async function readAndDecryptBackupFile(
export async function readAndDecryptBackupFileUsingFileSystemAPI(
fileHandle: FileHandleRead,
file: {
encryptionHeader: FileContent['encryptionHeader']
@@ -19,8 +19,8 @@ export async function readAndDecryptBackupFile(
): Promise<'aborted' | 'failed' | 'success'> {
const decryptor = new FileDecryptor(file, crypto)
const byteChunker = new OrderedByteChunker(file.encryptedChunkSizes, async (chunk: Uint8Array) => {
const decryptResult = decryptor.decryptBytes(chunk)
const byteChunker = new OrderedByteChunker(file.encryptedChunkSizes, 'local', async (chunk) => {
const decryptResult = decryptor.decryptBytes(chunk.data)
if (!decryptResult) {
return

View File

@@ -0,0 +1,52 @@
import { FileContent } from '@standardnotes/models'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { FileDecryptor } from '../UseCase/FileDecryptor'
import { OrderedByteChunker } from '../Chunker/OrderedByteChunker'
import { BackupServiceInterface } from './BackupServiceInterface'
import { OnChunkCallback } from '../Chunker/OnChunkCallback'
import { log, LoggingDomain } from '../Logging'
export async function readAndDecryptBackupFileUsingBackupService(
file: {
uuid: string
encryptionHeader: FileContent['encryptionHeader']
remoteIdentifier: FileContent['remoteIdentifier']
encryptedChunkSizes: FileContent['encryptedChunkSizes']
key: FileContent['key']
},
backupService: BackupServiceInterface,
crypto: PureCryptoInterface,
onDecryptedBytes: OnChunkCallback,
): Promise<'aborted' | 'failed' | 'success'> {
log(
LoggingDomain.FilesPackage,
'Reading and decrypting backup file',
file.uuid,
'chunk sizes',
file.encryptedChunkSizes,
)
const decryptor = new FileDecryptor(file, crypto)
const byteChunker = new OrderedByteChunker(file.encryptedChunkSizes, 'local', async (chunk) => {
log(LoggingDomain.FilesPackage, 'OrderedByteChunker did pop bytes', chunk.data.length, chunk.progress)
const decryptResult = decryptor.decryptBytes(chunk.data)
if (!decryptResult) {
return
}
await onDecryptedBytes({ ...chunk, data: decryptResult.decryptedBytes })
})
const readResult = await backupService.readEncryptedFileFromBackup(file.uuid, async (chunk) => {
log(LoggingDomain.FilesPackage, 'Got file chunk from backup service', chunk.data.length, chunk.progress)
await byteChunker.addBytes(chunk.data)
})
log(LoggingDomain.FilesPackage, 'Finished reading and decrypting backup file', file.uuid)
return readResult
}

View File

@@ -3,4 +3,24 @@ export type FileDownloadProgress = {
encryptedBytesDownloaded: number
encryptedBytesRemaining: number
percentComplete: number
source: 'network' | 'local' | 'memcache'
}
export function fileProgressToHumanReadableString(
progress: FileDownloadProgress,
fileName: string,
options: { showPercent: boolean },
): string {
const progressPercent = Math.floor(progress.percentComplete)
const sourceString =
progress.source === 'network' ? '' : progress.source === 'memcache' ? 'from cache' : 'from backup'
let result = `Downloading file ${sourceString} "${fileName}"`
if (options.showPercent) {
result += ` (${progressPercent}%)`
}
return result
}

View File

@@ -35,6 +35,7 @@ export class FileDownloader {
encryptedBytesDownloaded: this.totalBytesDownloaded,
encryptedBytesRemaining: encryptedSize - this.totalBytesDownloaded,
percentComplete: (this.totalBytesDownloaded / encryptedSize) * 100.0,
source: 'network',
}
}

View File

@@ -13,8 +13,10 @@ export * from './Device/FileBackupMetadataFile'
export * from './Device/FileBackupsConstantsV1'
export * from './Device/FileBackupsDevice'
export * from './Device/FileBackupsMapping'
export * from './Service/BackupServiceInterface'
export * from './Service/FilesClientInterface'
export * from './Service/ReadAndDecryptBackupFile'
export * from './Service/ReadAndDecryptBackupFileFileSystemAPI'
export * from './Service/ReadAndDecryptBackupFileUsingBackupService'
export * from './Operations/DownloadAndDecrypt'
export * from './Operations/EncryptAndUpload'
export * from './UseCase/FileDecryptor'

View File

@@ -13,7 +13,7 @@
"tsc": "tsc --project tsconfig.json",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"test": "jest spec --coverage"
"test": "jest --coverage"
},
"dependencies": {
"@standardnotes/api": "workspace:^",

View File

@@ -0,0 +1,109 @@
import { StatusServiceInterface } from './../Status/StatusServiceInterface'
import { FilesBackupService } from './BackupService'
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { InternalEventBusInterface } from '..'
import { AlertService } from '../Alert/AlertService'
import { ApiServiceInterface } from '../Api/ApiServiceInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { FileBackupsDevice } from '@standardnotes/files'
describe('backup service', () => {
let apiService: ApiServiceInterface
let itemManager: ItemManagerInterface
let syncService: SyncServiceInterface
let alertService: AlertService
let crypto: PureCryptoInterface
let status: StatusServiceInterface
let encryptor: EncryptionProviderInterface
let internalEventBus: InternalEventBusInterface
let backupService: FilesBackupService
let device: FileBackupsDevice
beforeEach(() => {
apiService = {} as jest.Mocked<ApiServiceInterface>
apiService.addEventObserver = jest.fn()
apiService.createFileValetToken = jest.fn()
apiService.downloadFile = jest.fn()
apiService.deleteFile = jest.fn().mockReturnValue({})
itemManager = {} as jest.Mocked<ItemManagerInterface>
itemManager.createItem = jest.fn()
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
itemManager.setItemToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
status = {} as jest.Mocked<StatusServiceInterface>
device = {} as jest.Mocked<FileBackupsDevice>
device.getFileBackupReadToken = jest.fn()
device.readNextChunk = jest.fn()
syncService = {} as jest.Mocked<SyncServiceInterface>
syncService.sync = jest.fn()
encryptor = {} as jest.Mocked<EncryptionProviderInterface>
alertService = {} as jest.Mocked<AlertService>
alertService.confirm = jest.fn().mockReturnValue(true)
alertService.alert = jest.fn()
crypto = {} as jest.Mocked<PureCryptoInterface>
crypto.base64Decode = jest.fn()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
backupService = new FilesBackupService(itemManager, apiService, encryptor, device, status, crypto, internalEventBus)
crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({
state: {},
} as StreamEncryptor)
crypto.xchacha20StreamDecryptorPush = jest.fn().mockReturnValue({ message: new Uint8Array([0xaa]), tag: 0 })
crypto.xchacha20StreamInitEncryptor = jest.fn().mockReturnValue({
header: 'some-header',
state: {},
} as StreamEncryptor)
crypto.xchacha20StreamEncryptorPush = jest.fn().mockReturnValue(new Uint8Array())
})
describe('readEncryptedFileFromBackup', () => {
it('return failed if no backup', async () => {
backupService.getFileBackupInfo = jest.fn().mockReturnValue(undefined)
const result = await backupService.readEncryptedFileFromBackup('123', async () => {})
expect(result).toEqual('failed')
})
it('return success if backup', async () => {
backupService.getFileBackupInfo = jest.fn().mockReturnValue({})
device.readNextChunk = jest.fn().mockReturnValue({ chunk: new Uint8Array([]), isLast: true, progress: undefined })
const result = await backupService.readEncryptedFileFromBackup('123', async () => {})
expect(result).toEqual('success')
})
it('should loop through all chunks until last', async () => {
backupService.getFileBackupInfo = jest.fn().mockReturnValue({})
const expectedChunkCount = 3
let receivedChunkCount = 0
const mockFn = (device.readNextChunk = jest.fn().mockImplementation(() => {
receivedChunkCount++
return { chunk: new Uint8Array([]), isLast: receivedChunkCount === expectedChunkCount, progress: undefined }
}))
await backupService.readEncryptedFileFromBackup('123', async () => {})
expect(mockFn.mock.calls.length).toEqual(expectedChunkCount)
})
})
})

View File

@@ -8,13 +8,17 @@ import {
FileBackupsDevice,
FileBackupsMapping,
FileBackupRecord,
OnChunkCallback,
BackupServiceInterface,
} from '@standardnotes/files'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { AbstractService } from '../Service/AbstractService'
import { StatusServiceInterface } from '../Status/StatusServiceInterface'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { log, LoggingDomain } from '../Logging'
export class FilesBackupService extends AbstractService {
export class FilesBackupService extends AbstractService implements BackupServiceInterface {
private itemsObserverDisposer: () => void
private pendingFiles = new Set<Uuid>()
private mappingCache?: FileBackupsMapping['files']
@@ -25,6 +29,7 @@ export class FilesBackupService extends AbstractService {
private encryptor: EncryptionProviderInterface,
private device: FileBackupsDevice,
private status: StatusServiceInterface,
private crypto: PureCryptoInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
@@ -43,7 +48,14 @@ export class FilesBackupService extends AbstractService {
}
override deinit() {
super.deinit()
this.itemsObserverDisposer()
;(this.items as unknown) = undefined
;(this.api as unknown) = undefined
;(this.encryptor as unknown) = undefined
;(this.device as unknown) = undefined
;(this.status as unknown) = undefined
;(this.crypto as unknown) = undefined
}
public isFilesBackupsEnabled(): Promise<boolean> {
@@ -98,7 +110,7 @@ export class FilesBackupService extends AbstractService {
return this.mappingCache ?? (await this.getBackupsMappingFromDisk())
}
public async getFileBackupInfo(file: FileItem): Promise<FileBackupRecord | undefined> {
public async getFileBackupInfo(file: { uuid: string }): Promise<FileBackupRecord | undefined> {
const mapping = await this.getBackupsMappingFromCache()
const record = mapping[file.uuid]
return record
@@ -138,7 +150,34 @@ export class FilesBackupService extends AbstractService {
this.invalidateMappingCache()
}
async readEncryptedFileFromBackup(uuid: string, onChunk: OnChunkCallback): Promise<'success' | 'failed' | 'aborted'> {
const fileBackup = await this.getFileBackupInfo({ uuid })
if (!fileBackup) {
return 'failed'
}
const token = await this.device.getFileBackupReadToken(fileBackup)
let readMore = true
let index = 0
while (readMore) {
const { chunk, isLast, progress } = await this.device.readNextChunk(token)
await onChunk({ data: chunk, index, isLast, progress })
readMore = !isLast
index++
}
return 'success'
}
private async performBackupOperation(file: FileItem): Promise<'success' | 'failed' | 'aborted'> {
log(LoggingDomain.FilesBackups, 'Backing up file locally', file.uuid)
const messageId = this.status.addMessage(`Backing up file ${file.name}...`)
const encryptedFile = await this.encryptor.encryptSplitSingle({

View File

@@ -1,15 +1,14 @@
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
import { FileItem } from '@standardnotes/models'
import { EncryptionProviderInterface } from '@standardnotes/encryption'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { ChallengeServiceInterface } from '../Challenge'
import { InternalEventBusInterface } from '..'
import { AlertService } from '../Alert/AlertService'
import { ApiServiceInterface } from '../Api/ApiServiceInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { FileService } from './FileService'
import { BackupServiceInterface } from '@standardnotes/files'
describe('fileService', () => {
let apiService: ApiServiceInterface
@@ -21,13 +20,33 @@ describe('fileService', () => {
let fileService: FileService
let encryptor: EncryptionProviderInterface
let internalEventBus: InternalEventBusInterface
let backupService: BackupServiceInterface
beforeEach(() => {
apiService = {} as jest.Mocked<ApiServiceInterface>
apiService.addEventObserver = jest.fn()
apiService.createFileValetToken = jest.fn()
apiService.downloadFile = jest.fn()
apiService.deleteFile = jest.fn().mockReturnValue({})
const numChunks = 1
apiService.downloadFile = jest
.fn()
.mockImplementation(
(
_file: string,
_chunkIndex: number,
_apiToken: string,
_rangeStart: number,
onBytesReceived: (bytes: Uint8Array) => void,
) => {
return new Promise<void>((resolve) => {
for (let i = 0; i < numChunks; i++) {
onBytesReceived(Uint8Array.from([0xaa]))
}
resolve()
})
},
)
itemManager = {} as jest.Mocked<ItemManagerInterface>
itemManager.createItem = jest.fn()
@@ -52,6 +71,10 @@ describe('fileService', () => {
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
backupService = {} as jest.Mocked<BackupServiceInterface>
backupService.readEncryptedFileFromBackup = jest.fn()
backupService.getFileBackupInfo = jest.fn()
fileService = new FileService(
apiService,
itemManager,
@@ -61,6 +84,7 @@ describe('fileService', () => {
alertService,
crypto,
internalEventBus,
backupService,
)
crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({
@@ -110,6 +134,8 @@ describe('fileService', () => {
decryptedSize: 100_000,
} as jest.Mocked<FileItem>
apiService.downloadFile = jest.fn()
await fileService.downloadFile(file, async () => {
return Promise.resolve()
})
@@ -118,4 +144,42 @@ describe('fileService', () => {
expect(fileService['encryptedCache'].get(file.uuid)).toBeFalsy()
})
it('should download file from network if no backup', async () => {
const file = {
uuid: '1',
decryptedSize: 100_000,
encryptedSize: 101_000,
encryptedChunkSizes: [101_000],
} as jest.Mocked<FileItem>
backupService.getFileBackupInfo = jest.fn().mockReturnValue(undefined)
const downloadMock = apiService.downloadFile as jest.Mock
await fileService.downloadFile(file, async () => {
return Promise.resolve()
})
expect(downloadMock).toHaveBeenCalledTimes(1)
})
it('should download file from local backup if it exists', async () => {
const file = {
uuid: '1',
decryptedSize: 100_000,
encryptedSize: 101_000,
encryptedChunkSizes: [101_000],
} as jest.Mocked<FileItem>
backupService.getFileBackupInfo = jest.fn().mockReturnValue({})
const downloadMock = (apiService.downloadFile = jest.fn())
await fileService.downloadFile(file, async () => {
return Promise.resolve()
})
expect(downloadMock).toHaveBeenCalledTimes(0)
})
})

View File

@@ -19,7 +19,7 @@ import {
FileDecryptor,
FileDownloadProgress,
FilesClientInterface,
readAndDecryptBackupFile,
readAndDecryptBackupFileUsingFileSystemAPI,
FilesApiInterface,
FileBackupsConstantsV1,
FileBackupMetadataFile,
@@ -30,8 +30,9 @@ import {
DecryptedBytes,
OrderedByteChunker,
FileMemoryCache,
readAndDecryptBackupFileUsingBackupService,
BackupServiceInterface,
} from '@standardnotes/files'
import { AlertService } from '../Alert/AlertService'
import { ChallengeServiceInterface } from '../Challenge'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
@@ -39,6 +40,7 @@ import { ItemManagerInterface } from '../Item/ItemManagerInterface'
import { AbstractService } from '../Service/AbstractService'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { DecryptItemsKeyWithUserFallback } from '../Encryption/Functions'
import { log, LoggingDomain } from '../Logging'
const OneHundredMb = 100 * 1_000_000
@@ -54,6 +56,7 @@ export class FileService extends AbstractService implements FilesClientInterface
private alertService: AlertService,
private crypto: PureCryptoInterface,
protected override internalEventBus: InternalEventBusInterface,
private backupsService?: BackupServiceInterface,
) {
super(internalEventBus)
}
@@ -158,8 +161,8 @@ export class FileService extends AbstractService implements FilesClientInterface
let decryptedAggregate = new Uint8Array()
const orderedChunker = new OrderedByteChunker(file.encryptedChunkSizes, async (encryptedBytes) => {
const decryptedBytes = decryptOperation.decryptBytes(encryptedBytes)
const orderedChunker = new OrderedByteChunker(file.encryptedChunkSizes, 'memcache', async (chunk) => {
const decryptedBytes = decryptOperation.decryptBytes(chunk.data)
if (decryptedBytes) {
decryptedAggregate = new Uint8Array([...decryptedAggregate, ...decryptedBytes.decryptedBytes])
@@ -173,36 +176,60 @@ export class FileService extends AbstractService implements FilesClientInterface
public async downloadFile(
file: FileItem,
onDecryptedBytes: (decryptedBytes: Uint8Array, progress?: FileDownloadProgress) => Promise<void>,
onDecryptedBytes: (decryptedBytes: Uint8Array, progress: FileDownloadProgress) => Promise<void>,
): Promise<ClientDisplayableError | undefined> {
const cachedBytes = this.encryptedCache.get(file.uuid)
if (cachedBytes) {
const decryptedBytes = await this.decryptCachedEntry(file, cachedBytes)
await onDecryptedBytes(decryptedBytes.decryptedBytes, undefined)
await onDecryptedBytes(decryptedBytes.decryptedBytes, {
encryptedFileSize: cachedBytes.encryptedBytes.length,
encryptedBytesDownloaded: cachedBytes.encryptedBytes.length,
encryptedBytesRemaining: 0,
percentComplete: 100,
source: 'memcache',
})
return undefined
}
const addToCache = file.encryptedSize < this.encryptedCache.maxSize
const fileBackup = await this.backupsService?.getFileBackupInfo(file)
let cacheEntryAggregate = new Uint8Array()
if (this.backupsService && fileBackup) {
log(LoggingDomain.FilesService, 'Downloading file from backup', fileBackup)
const operation = new DownloadAndDecryptFileOperation(file, this.crypto, this.api)
await readAndDecryptBackupFileUsingBackupService(file, this.backupsService, this.crypto, async (chunk) => {
log(LoggingDomain.FilesService, 'Got local file chunk', chunk.progress)
const result = await operation.run(async ({ decrypted, encrypted, progress }): Promise<void> => {
if (addToCache) {
cacheEntryAggregate = new Uint8Array([...cacheEntryAggregate, ...encrypted.encryptedBytes])
return onDecryptedBytes(chunk.data, chunk.progress)
})
log(LoggingDomain.FilesService, 'Finished downloading file from backup')
return undefined
} else {
log(LoggingDomain.FilesService, 'Downloading file from network')
const addToCache = file.encryptedSize < this.encryptedCache.maxSize
let cacheEntryAggregate = new Uint8Array()
const operation = new DownloadAndDecryptFileOperation(file, this.crypto, this.api)
const result = await operation.run(async ({ decrypted, encrypted, progress }): Promise<void> => {
if (addToCache) {
cacheEntryAggregate = new Uint8Array([...cacheEntryAggregate, ...encrypted.encryptedBytes])
}
return onDecryptedBytes(decrypted.decryptedBytes, progress)
})
if (addToCache && cacheEntryAggregate.byteLength > 0) {
this.encryptedCache.add(file.uuid, { encryptedBytes: cacheEntryAggregate })
}
return onDecryptedBytes(decrypted.decryptedBytes, progress)
})
if (addToCache) {
this.encryptedCache.add(file.uuid, { encryptedBytes: cacheEntryAggregate })
return result.error
}
return result.error
}
public async deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined> {
@@ -294,9 +321,15 @@ export class FileService extends AbstractService implements FilesClientInterface
return destinationFileHandle
}
const result = await readAndDecryptBackupFile(fileHandle, file, fileSystem, this.crypto, async (decryptedBytes) => {
await fileSystem.saveBytes(destinationFileHandle, decryptedBytes)
})
const result = await readAndDecryptBackupFileUsingFileSystemAPI(
fileHandle,
file,
fileSystem,
this.crypto,
async (decryptedBytes) => {
await fileSystem.saveBytes(destinationFileHandle, decryptedBytes)
},
)
await fileSystem.closeFileWriteStream(destinationFileHandle)
@@ -310,9 +343,15 @@ export class FileService extends AbstractService implements FilesClientInterface
): Promise<Uint8Array> {
let bytes = new Uint8Array()
await readAndDecryptBackupFile(fileHandle, file, fileSystem, this.crypto, async (decryptedBytes) => {
bytes = new Uint8Array([...bytes, ...decryptedBytes])
})
await readAndDecryptBackupFileUsingFileSystemAPI(
fileHandle,
file,
fileSystem,
this.crypto,
async (decryptedBytes) => {
bytes = new Uint8Array([...bytes, ...decryptedBytes])
},
)
return bytes
}

View File

@@ -0,0 +1,33 @@
import { logWithColor } from '@standardnotes/utils'
declare const process: {
env: {
NODE_ENV: string | null | undefined
}
}
export const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
export enum LoggingDomain {
FilesService,
FilesBackups,
}
const LoggingStatus: Record<LoggingDomain, boolean> = {
[LoggingDomain.FilesService]: false,
[LoggingDomain.FilesBackups]: false,
}
const LoggingColor: Record<LoggingDomain, string> = {
[LoggingDomain.FilesService]: 'blue',
[LoggingDomain.FilesBackups]: 'yellow',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function log(domain: LoggingDomain, ...args: any[]): void {
if (!isDev || !LoggingStatus[domain]) {
return
}
logWithColor(LoggingDomain[domain], LoggingColor[domain], ...args)
}

View File

@@ -64,7 +64,7 @@ import {
SessionStrings,
AccountEvent,
} from '@standardnotes/services'
import { FilesClientInterface } from '@standardnotes/files'
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
import { ComputePrivateUsername } from '@standardnotes/encryption'
import { useBoolean } from '@standardnotes/utils'
import {
@@ -275,7 +275,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this.statusService
}
public get fileBackups(): FilesBackupService | undefined {
public get fileBackups(): BackupServiceInterface | undefined {
return this.filesBackupService
}
@@ -1113,16 +1113,17 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.createComponentManager()
this.createMigrationService()
this.createMfaService()
this.createStatusService()
if (isDesktopDevice(this.deviceInterface)) {
this.createFilesBackupService(this.deviceInterface)
}
this.createFileService()
this.createIntegrityService()
this.createMutatorService()
this.createListedService()
this.createActionsManager()
this.createStatusService()
if (isDesktopDevice(this.deviceInterface)) {
this.createFilesBackupService(this.deviceInterface)
}
}
private clearServices() {
@@ -1210,6 +1211,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.alertService,
this.options.crypto,
this.internalEventBus,
this.fileBackups,
)
this.services.push(this.fileService)
@@ -1670,6 +1672,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.protocolService,
device,
this.statusService,
this.options.crypto,
this.internalEventBus,
)
this.services.push(this.filesBackupService)

View File

@@ -655,11 +655,15 @@ export function secondHalfOfString(string: string): string {
}
export function log(namespace: string, ...args: any[]): void {
logWithColor(namespace, 'black', ...args)
}
export function logWithColor(namespace: string, namespaceColor: string, ...args: any[]): void {
const date = new Date()
const timeString = `${date.toLocaleTimeString().replace(' PM', '').replace(' AM', '')}.${date.getMilliseconds()}`
customLog(
`%c${namespace}%c${timeString}`,
'color: black; font-weight: bold; margin-right: 4px',
`color: ${namespaceColor}; font-weight: bold; margin-right: 4px`,
'color: gray',
...args,
)

View File

@@ -7,4 +7,7 @@ export {
FileBackupsMapping,
FileBackupsDevice,
FileBackupRecord,
FileBackupReadToken,
FileBackupReadChunkResponse,
FileDownloadProgress,
} from '@standardnotes/snjs'

View File

@@ -1,6 +1,11 @@
import { WebApplication } from '@/Application/Application'
import { concatenateUint8Arrays } from '@/Utils'
import { ApplicationEvent, FileItem } from '@standardnotes/snjs'
import {
ApplicationEvent,
FileDownloadProgress,
FileItem,
fileProgressToHumanReadableString,
} from '@standardnotes/snjs'
import { useEffect, useMemo, useState } from 'react'
import Spinner from '@/Components/Spinner/Spinner'
import FilePreviewError from './FilePreviewError'
@@ -24,7 +29,7 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
}, [file.mimeType])
const [isDownloading, setIsDownloading] = useState(true)
const [downloadProgress, setDownloadProgress] = useState(0)
const [downloadProgress, setDownloadProgress] = useState<FileDownloadProgress | undefined>()
const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>()
useEffect(() => {
@@ -46,7 +51,7 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
useEffect(() => {
if (!isFilePreviewable || !isAuthorized) {
setIsDownloading(false)
setDownloadProgress(0)
setDownloadProgress(undefined)
setDownloadedBytes(undefined)
return
}
@@ -60,15 +65,18 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
try {
const chunks: Uint8Array[] = []
setDownloadProgress(0)
await application.files.downloadFile(file, async (decryptedChunk, progress) => {
setDownloadProgress(undefined)
const error = await application.files.downloadFile(file, async (decryptedChunk, progress) => {
chunks.push(decryptedChunk)
if (progress) {
setDownloadProgress(Math.round(progress.percentComplete))
setDownloadProgress(progress)
}
})
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
setDownloadedBytes(finalDecryptedBytes)
if (!error) {
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
setDownloadedBytes(finalDecryptedBytes)
}
} catch (error) {
console.error(error)
} finally {
@@ -109,9 +117,17 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
<div className="flex flex-grow flex-col items-center justify-center">
<div className="flex items-center">
<Spinner className="mr-3 h-5 w-5" />
<div className="text-base font-semibold">{downloadProgress}%</div>
{downloadProgress && (
<div className="text-base font-semibold">{Math.floor(downloadProgress.percentComplete)}%</div>
)}
</div>
<span className="mt-3">Loading file...</span>
{downloadProgress ? (
<span className="mt-3">
{fileProgressToHumanReadableString(downloadProgress, file.name, { showPercent: false })}
</span>
) : (
<span className="mt-3">Loading...</span>
)}
</div>
) : downloadedBytes ? (
<PreviewComponent

View File

@@ -73,10 +73,10 @@ const FileBackupsDesktop = ({ application, backupsService }: Props) => {
)}
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
{backupsEnabled && (
<>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
<>
<Text className="mb-3">

View File

@@ -1,3 +1,8 @@
import {
FileDownloadProgress,
fileProgressToHumanReadableString,
OnChunkCallbackNoProgress,
} from '@standardnotes/files'
import { FilePreviewModalController } from './FilePreviewModalController'
import {
PopoverFileItemAction,
@@ -260,6 +265,8 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
const decryptedBytesArray: Uint8Array[] = []
let lastProgress: FileDownloadProgress | undefined
const result = await this.application.files.downloadFile(file, async (decryptedBytes, progress) => {
if (isUsingStreamingSaver) {
await saver.pushBytes(decryptedBytes)
@@ -267,14 +274,14 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
decryptedBytesArray.push(decryptedBytes)
}
if (progress) {
const progressPercent = Math.floor(progress.percentComplete)
const progressPercent = Math.floor(progress.percentComplete)
updateToast(downloadingToastId, {
message: `Downloading file "${file.name}" (${progressPercent}%)`,
progress: progressPercent,
})
}
updateToast(downloadingToastId, {
message: fileProgressToHumanReadableString(progress, file.name, { showPercent: true }),
progress: progressPercent,
})
lastProgress = progress
})
if (result instanceof ClientDisplayableError) {
@@ -293,7 +300,9 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
addToast({
type: ToastType.Success,
message: 'Successfully downloaded file',
message: `Successfully downloaded file${
lastProgress && lastProgress.source === 'local' ? ' from local backup' : ''
}`,
})
} catch (error) {
console.error(error)
@@ -364,13 +373,13 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
progress: initialProgress,
})
const onChunk = async (chunk: Uint8Array, index: number, isLast: boolean) => {
await this.application.files.pushBytesForUpload(operation, chunk, index, isLast)
const onChunk: OnChunkCallbackNoProgress = async ({ data, index, isLast }) => {
await this.application.files.pushBytesForUpload(operation, data, index, isLast)
const progress = Math.round(operation.getProgress().percentComplete)
const percentComplete = Math.round(operation.getProgress().percentComplete)
updateToast(toastId, {
message: `Uploading file "${file.name}" (${progress}%)`,
progress,
message: `Uploading file "${file.name}" (${percentComplete}%)`,
progress: percentComplete,
})
}