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 { AppState } from 'app/AppState'
|
||||||
import { shell } from 'electron'
|
import { shell } from 'electron'
|
||||||
import { StoreKeys } from '../Store/StoreKeys'
|
import { StoreKeys } from '../Store/StoreKeys'
|
||||||
@@ -6,13 +12,14 @@ import path from 'path'
|
|||||||
import {
|
import {
|
||||||
deleteFile,
|
deleteFile,
|
||||||
ensureDirectoryExists,
|
ensureDirectoryExists,
|
||||||
moveFiles,
|
moveDirContents,
|
||||||
openDirectoryPicker,
|
openDirectoryPicker,
|
||||||
readJSONFile,
|
readJSONFile,
|
||||||
writeFile,
|
writeFile,
|
||||||
writeJSONFile,
|
writeJSONFile,
|
||||||
} from '../Utils/FileUtils'
|
} from '../Utils/FileUtils'
|
||||||
import { FileDownloader } from './FileDownloader'
|
import { FileDownloader } from './FileDownloader'
|
||||||
|
import { FileReadOperation } from './FileReadOperation'
|
||||||
|
|
||||||
export const FileBackupsConstantsV1 = {
|
export const FileBackupsConstantsV1 = {
|
||||||
Version: '1.0.0',
|
Version: '1.0.0',
|
||||||
@@ -21,6 +28,8 @@ export const FileBackupsConstantsV1 = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class FilesBackupManager implements FileBackupsDevice {
|
export class FilesBackupManager implements FileBackupsDevice {
|
||||||
|
private readOperations: Map<string, FileReadOperation> = new Map()
|
||||||
|
|
||||||
constructor(private appState: AppState) {}
|
constructor(private appState: AppState) {}
|
||||||
|
|
||||||
public isFilesBackupsEnabled(): Promise<boolean> {
|
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||||
@@ -78,8 +87,11 @@ export class FilesBackupManager implements FileBackupsDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.values(mapping.files)
|
const entries = Object.values(mapping.files)
|
||||||
const itemFolders = entries.map((entry) => path.join(oldPath, entry.relativePath))
|
for (const entry of entries) {
|
||||||
await moveFiles(itemFolders, newPath)
|
const sourcePath = path.join(oldPath, entry.relativePath)
|
||||||
|
const destinationPath = path.join(newPath, entry.relativePath)
|
||||||
|
await moveDirContents(sourcePath, destinationPath)
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
entry.absolutePath = path.join(newPath, entry.relativePath)
|
entry.absolutePath = path.join(newPath, entry.relativePath)
|
||||||
@@ -188,4 +200,28 @@ export class FilesBackupManager implements FileBackupsDevice {
|
|||||||
|
|
||||||
return result
|
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 path = require('path')
|
||||||
const rendererPath = path.join('file://', __dirname, '/renderer.js')
|
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 { app, BrowserWindow } from 'electron'
|
||||||
import { BackupsManagerInterface } from '../Backups/BackupsManagerInterface'
|
import { BackupsManagerInterface } from '../Backups/BackupsManagerInterface'
|
||||||
import { KeychainInterface } from '../Keychain/KeychainInterface'
|
import { KeychainInterface } from '../Keychain/KeychainInterface'
|
||||||
@@ -65,6 +71,8 @@ export class RemoteBridge implements CrossProcessBridge {
|
|||||||
getFilesBackupsLocation: this.getFilesBackupsLocation.bind(this),
|
getFilesBackupsLocation: this.getFilesBackupsLocation.bind(this),
|
||||||
openFilesBackupsLocation: this.openFilesBackupsLocation.bind(this),
|
openFilesBackupsLocation: this.openFilesBackupsLocation.bind(this),
|
||||||
openFileBackup: this.openFileBackup.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)
|
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> {
|
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||||
return this.fileBackups.isFilesBackupsEnabled()
|
return this.fileBackups.isFilesBackupsEnabled()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { removeFromArray } from '../Utils/Utils'
|
|||||||
|
|
||||||
export const FileDoesNotExist = 'ENOENT'
|
export const FileDoesNotExist = 'ENOENT'
|
||||||
export const FileAlreadyExists = 'EEXIST'
|
export const FileAlreadyExists = 'EEXIST'
|
||||||
const CrossDeviceLink = 'EXDEV'
|
|
||||||
const OperationNotPermitted = 'EPERM'
|
const OperationNotPermitted = 'EPERM'
|
||||||
const DeviceIsBusy = 'EBUSY'
|
const DeviceIsBusy = 'EBUSY'
|
||||||
|
|
||||||
@@ -152,8 +151,14 @@ function isChildOfDir(parent: string, potentialChild: string) {
|
|||||||
return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
|
return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveDirContents(srcDir: string, destDir: string): Promise<void[]> {
|
export async function moveDirContents(srcDir: string, destDir: string): Promise<void> {
|
||||||
let fileNames = await fs.promises.readdir(srcDir)
|
let fileNames: string[]
|
||||||
|
try {
|
||||||
|
fileNames = await fs.promises.readdir(srcDir)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
await ensureDirectoryExists(destDir)
|
await ensureDirectoryExists(destDir)
|
||||||
|
|
||||||
if (isChildOfDir(srcDir, destDir)) {
|
if (isChildOfDir(srcDir, destDir)) {
|
||||||
@@ -163,10 +168,14 @@ export async function moveDirContents(srcDir: string, destDir: string): Promise<
|
|||||||
removeFromArray(fileNames, path.basename(destDir))
|
removeFromArray(fileNames, path.basename(destDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
return moveFiles(
|
try {
|
||||||
fileNames.map((fileName) => path.join(srcDir, fileName)),
|
await moveFiles(
|
||||||
destDir,
|
fileNames.map((fileName) => path.join(srcDir, fileName)),
|
||||||
)
|
destDir,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractZip(source: string, dest: string): Promise<void> {
|
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) {
|
async function moveFile(source: PathLike, destination: PathLike) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.rename(source, destination)
|
await fs.promises.rename(source, destination)
|
||||||
} catch (error: any) {
|
} catch (_error) {
|
||||||
if (error.code === CrossDeviceLink) {
|
/** Fall back to copying and then deleting. */
|
||||||
/** Fall back to copying and then deleting. */
|
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_FICLONE_FORCE)
|
||||||
await fs.promises.copyFile(source, destination)
|
await fs.promises.unlink(source)
|
||||||
await fs.promises.unlink(source)
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
FileBackupsMapping,
|
FileBackupsMapping,
|
||||||
RawKeychainValue,
|
RawKeychainValue,
|
||||||
FileBackupRecord,
|
FileBackupRecord,
|
||||||
|
FileBackupReadToken,
|
||||||
|
FileBackupReadChunkResponse,
|
||||||
} from '@web/Application/Device/DesktopSnjsExports'
|
} from '@web/Application/Device/DesktopSnjsExports'
|
||||||
import { WebOrDesktopDevice } from '@web/Application/Device/WebOrDesktopDevice'
|
import { WebOrDesktopDevice } from '@web/Application/Device/WebOrDesktopDevice'
|
||||||
import { Component } from '../Main/Packages/PackageManagerInterface'
|
import { Component } from '../Main/Packages/PackageManagerInterface'
|
||||||
@@ -149,6 +151,14 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn
|
|||||||
return this.remoteBridge.saveFilesBackupsFile(uuid, metaFile, downloadRequest)
|
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> {
|
async performHardReset(): Promise<void> {
|
||||||
console.error('performHardReset is not yet implemented')
|
console.error('performHardReset is not yet implemented')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ByteChunker, OnChunkCallback } from '@standardnotes/files'
|
import { ByteChunker, OnChunkCallbackNoProgress } from '@standardnotes/files'
|
||||||
import { FileSelectionResponse } from '../types'
|
import { FileSelectionResponse } from '../types'
|
||||||
import { readFile as utilsReadFile } from '../utils'
|
import { readFile as utilsReadFile } from '../utils'
|
||||||
import { FileReaderInterface } from '../Interface/FileReader'
|
import { FileReaderInterface } from '../Interface/FileReader'
|
||||||
@@ -39,7 +39,7 @@ function selectFiles(): Promise<File[]> {
|
|||||||
async function readFile(
|
async function readFile(
|
||||||
file: File,
|
file: File,
|
||||||
minimumChunkSize: number,
|
minimumChunkSize: number,
|
||||||
onChunk: OnChunkCallback,
|
onChunk: OnChunkCallbackNoProgress,
|
||||||
): Promise<FileSelectionResponse> {
|
): Promise<FileSelectionResponse> {
|
||||||
const buffer = await utilsReadFile(file)
|
const buffer = await utilsReadFile(file)
|
||||||
const chunker = new ByteChunker(minimumChunkSize, onChunk)
|
const chunker = new ByteChunker(minimumChunkSize, onChunk)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { OnChunkCallback } from '@standardnotes/files'
|
import { OnChunkCallbackNoProgress } from '@standardnotes/files'
|
||||||
|
|
||||||
import { FileSelectionResponse } from '../types'
|
import { FileSelectionResponse } from '../types'
|
||||||
|
|
||||||
export interface FileReaderInterface {
|
export interface FileReaderInterface {
|
||||||
selectFiles(): Promise<File[]>
|
selectFiles(): Promise<File[]>
|
||||||
|
|
||||||
readFile(file: File, minimumChunkSize: number, onChunk: OnChunkCallback): Promise<FileSelectionResponse>
|
readFile(file: File, minimumChunkSize: number, onChunk: OnChunkCallbackNoProgress): Promise<FileSelectionResponse>
|
||||||
|
|
||||||
available(): boolean
|
available(): boolean
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ByteChunker, OnChunkCallback } from '@standardnotes/files'
|
import { ByteChunker, OnChunkCallbackNoProgress } from '@standardnotes/files'
|
||||||
import { FileReaderInterface } from './../Interface/FileReader'
|
import { FileReaderInterface } from './../Interface/FileReader'
|
||||||
import { FileSelectionResponse } from '../types'
|
import { FileSelectionResponse } from '../types'
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ async function selectFiles(): Promise<File[]> {
|
|||||||
async function readFile(
|
async function readFile(
|
||||||
file: File,
|
file: File,
|
||||||
minimumChunkSize: number,
|
minimumChunkSize: number,
|
||||||
onChunk: OnChunkCallback,
|
onChunk: OnChunkCallbackNoProgress,
|
||||||
): Promise<FileSelectionResponse> {
|
): Promise<FileSelectionResponse> {
|
||||||
const byteChunker = new ByteChunker(minimumChunkSize, onChunk)
|
const byteChunker = new ByteChunker(minimumChunkSize, onChunk)
|
||||||
const stream = file.stream() as unknown as ReadableStream
|
const stream = file.stream() as unknown as ReadableStream
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ describe('byte chunker', () => {
|
|||||||
it('should hold back small chunks until minimum size is met', async () => {
|
it('should hold back small chunks until minimum size is met', async () => {
|
||||||
let receivedBytes = new Uint8Array()
|
let receivedBytes = new Uint8Array()
|
||||||
let numChunks = 0
|
let numChunks = 0
|
||||||
const chunker = new ByteChunker(100, async (bytes) => {
|
const chunker = new ByteChunker(100, async (chunk) => {
|
||||||
numChunks++
|
numChunks++
|
||||||
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
|
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
|
||||||
})
|
})
|
||||||
|
|
||||||
await chunker.addBytes(chunkOfSize(50), false)
|
await chunker.addBytes(chunkOfSize(50), false)
|
||||||
@@ -25,9 +25,9 @@ describe('byte chunker', () => {
|
|||||||
it('should send back big chunks immediately', async () => {
|
it('should send back big chunks immediately', async () => {
|
||||||
let receivedBytes = new Uint8Array()
|
let receivedBytes = new Uint8Array()
|
||||||
let numChunks = 0
|
let numChunks = 0
|
||||||
const chunker = new ByteChunker(100, async (bytes) => {
|
const chunker = new ByteChunker(100, async (chunk) => {
|
||||||
numChunks++
|
numChunks++
|
||||||
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
|
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
|
||||||
})
|
})
|
||||||
|
|
||||||
await chunker.addBytes(chunkOfSize(150), false)
|
await chunker.addBytes(chunkOfSize(150), false)
|
||||||
@@ -42,9 +42,9 @@ describe('byte chunker', () => {
|
|||||||
it('last chunk should be popped regardless of size', async () => {
|
it('last chunk should be popped regardless of size', async () => {
|
||||||
let receivedBytes = new Uint8Array()
|
let receivedBytes = new Uint8Array()
|
||||||
let numChunks = 0
|
let numChunks = 0
|
||||||
const chunker = new ByteChunker(100, async (bytes) => {
|
const chunker = new ByteChunker(100, async (chunk) => {
|
||||||
numChunks++
|
numChunks++
|
||||||
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
|
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
|
||||||
})
|
})
|
||||||
|
|
||||||
await chunker.addBytes(chunkOfSize(50), false)
|
await chunker.addBytes(chunkOfSize(50), false)
|
||||||
@@ -57,9 +57,9 @@ describe('byte chunker', () => {
|
|||||||
it('single chunk should be popped immediately', async () => {
|
it('single chunk should be popped immediately', async () => {
|
||||||
let receivedBytes = new Uint8Array()
|
let receivedBytes = new Uint8Array()
|
||||||
let numChunks = 0
|
let numChunks = 0
|
||||||
const chunker = new ByteChunker(100, async (bytes) => {
|
const chunker = new ByteChunker(100, async (chunk) => {
|
||||||
numChunks++
|
numChunks++
|
||||||
receivedBytes = new Uint8Array([...receivedBytes, ...bytes])
|
receivedBytes = new Uint8Array([...receivedBytes, ...chunk.data])
|
||||||
})
|
})
|
||||||
|
|
||||||
await chunker.addBytes(chunkOfSize(50), true)
|
await chunker.addBytes(chunkOfSize(50), true)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { OnChunkCallback } from './OnChunkCallback'
|
import { OnChunkCallbackNoProgress } from './OnChunkCallback'
|
||||||
|
|
||||||
export class ByteChunker {
|
export class ByteChunker {
|
||||||
public loggingEnabled = false
|
public loggingEnabled = false
|
||||||
private bytes = new Uint8Array()
|
private bytes = new Uint8Array()
|
||||||
private index = 1
|
private index = 1
|
||||||
|
|
||||||
constructor(private minimumChunkSize: number, private onChunk: OnChunkCallback) {}
|
constructor(private minimumChunkSize: number, private onChunk: OnChunkCallbackNoProgress) {}
|
||||||
|
|
||||||
private log(...args: any[]): void {
|
private log(...args: any[]): void {
|
||||||
if (!this.loggingEnabled) {
|
if (!this.loggingEnabled) {
|
||||||
@@ -27,9 +27,13 @@ export class ByteChunker {
|
|||||||
|
|
||||||
private async popBytes(isLast: boolean): Promise<void> {
|
private async popBytes(isLast: boolean): Promise<void> {
|
||||||
const maxIndex = Math.max(this.minimumChunkSize, this.bytes.length)
|
const maxIndex = Math.max(this.minimumChunkSize, this.bytes.length)
|
||||||
|
|
||||||
const chunk = this.bytes.slice(0, maxIndex)
|
const chunk = this.bytes.slice(0, maxIndex)
|
||||||
|
|
||||||
this.bytes = new Uint8Array([...this.bytes.slice(maxIndex)])
|
this.bytes = new Uint8Array([...this.bytes.slice(maxIndex)])
|
||||||
|
|
||||||
this.log(`Chunker popping ${chunk.length}, total size in queue ${this.bytes.length}`)
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -10,9 +10,30 @@ describe('ordered byte chunker', () => {
|
|||||||
let receivedBytes = new Uint8Array()
|
let receivedBytes = new Uint8Array()
|
||||||
let numCallbacks = 0
|
let numCallbacks = 0
|
||||||
|
|
||||||
const chunker = new OrderedByteChunker(chunkSizes, async (bytes) => {
|
const chunker = new OrderedByteChunker(chunkSizes, 'network', async (chunk) => {
|
||||||
numCallbacks++
|
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))
|
await chunker.addBytes(chunkOfSize(30))
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
|
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||||
|
import { OnChunkCallback } from './OnChunkCallback'
|
||||||
|
|
||||||
export class OrderedByteChunker {
|
export class OrderedByteChunker {
|
||||||
private bytes = new Uint8Array()
|
private bytes = new Uint8Array()
|
||||||
private index = 1
|
private index = 1
|
||||||
private remainingChunks: number[] = []
|
private remainingChunks: number[] = []
|
||||||
|
private fileSize: number
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private chunkSizes: number[],
|
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.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 {
|
private needsPop(): boolean {
|
||||||
@@ -31,7 +46,18 @@ export class OrderedByteChunker {
|
|||||||
|
|
||||||
this.remainingChunks.shift()
|
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()) {
|
if (this.needsPop()) {
|
||||||
await this.popBytes()
|
await this.popBytes()
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Uuid } from '@standardnotes/common'
|
import { Uuid } from '@standardnotes/common'
|
||||||
|
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||||
import { FileBackupRecord, FileBackupsMapping } from './FileBackupsMapping'
|
import { FileBackupRecord, FileBackupsMapping } from './FileBackupsMapping'
|
||||||
|
|
||||||
|
export type FileBackupReadToken = string
|
||||||
|
export type FileBackupReadChunkResponse = { chunk: Uint8Array; isLast: boolean; progress: FileDownloadProgress }
|
||||||
|
|
||||||
export interface FileBackupsDevice {
|
export interface FileBackupsDevice {
|
||||||
getFilesBackupsMappingFile(): Promise<FileBackupsMapping>
|
getFilesBackupsMappingFile(): Promise<FileBackupsMapping>
|
||||||
saveFilesBackupsFile(
|
saveFilesBackupsFile(
|
||||||
@@ -12,6 +16,8 @@ export interface FileBackupsDevice {
|
|||||||
url: string
|
url: string
|
||||||
},
|
},
|
||||||
): Promise<'success' | 'failed'>
|
): Promise<'success' | 'failed'>
|
||||||
|
getFileBackupReadToken(record: FileBackupRecord): Promise<FileBackupReadToken>
|
||||||
|
readNextChunk(token: string): Promise<FileBackupReadChunkResponse>
|
||||||
isFilesBackupsEnabled(): Promise<boolean>
|
isFilesBackupsEnabled(): Promise<boolean>
|
||||||
enableFilesBackups(): Promise<void>
|
enableFilesBackups(): Promise<void>
|
||||||
disableFilesBackups(): Promise<void>
|
disableFilesBackups(): Promise<void>
|
||||||
|
|||||||
30
packages/files/src/Domain/Logging.ts
Normal file
30
packages/files/src/Domain/Logging.ts
Normal 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)
|
||||||
|
}
|
||||||
14
packages/files/src/Domain/Service/BackupServiceInterface.ts
Normal file
14
packages/files/src/Domain/Service/BackupServiceInterface.ts
Normal 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>
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export interface FilesClientInterface {
|
|||||||
|
|
||||||
downloadFile(
|
downloadFile(
|
||||||
file: FileItem,
|
file: FileItem,
|
||||||
onDecryptedBytes: (bytes: Uint8Array, progress: FileDownloadProgress | undefined) => Promise<void>,
|
onDecryptedBytes: (bytes: Uint8Array, progress: FileDownloadProgress) => Promise<void>,
|
||||||
): Promise<ClientDisplayableError | undefined>
|
): Promise<ClientDisplayableError | undefined>
|
||||||
|
|
||||||
deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined>
|
deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FileSystemApi } from '../Api/FileSystemApi'
|
|||||||
import { FileHandleRead } from '../Api/FileHandleRead'
|
import { FileHandleRead } from '../Api/FileHandleRead'
|
||||||
import { OrderedByteChunker } from '../Chunker/OrderedByteChunker'
|
import { OrderedByteChunker } from '../Chunker/OrderedByteChunker'
|
||||||
|
|
||||||
export async function readAndDecryptBackupFile(
|
export async function readAndDecryptBackupFileUsingFileSystemAPI(
|
||||||
fileHandle: FileHandleRead,
|
fileHandle: FileHandleRead,
|
||||||
file: {
|
file: {
|
||||||
encryptionHeader: FileContent['encryptionHeader']
|
encryptionHeader: FileContent['encryptionHeader']
|
||||||
@@ -19,8 +19,8 @@ export async function readAndDecryptBackupFile(
|
|||||||
): Promise<'aborted' | 'failed' | 'success'> {
|
): Promise<'aborted' | 'failed' | 'success'> {
|
||||||
const decryptor = new FileDecryptor(file, crypto)
|
const decryptor = new FileDecryptor(file, crypto)
|
||||||
|
|
||||||
const byteChunker = new OrderedByteChunker(file.encryptedChunkSizes, async (chunk: Uint8Array) => {
|
const byteChunker = new OrderedByteChunker(file.encryptedChunkSizes, 'local', async (chunk) => {
|
||||||
const decryptResult = decryptor.decryptBytes(chunk)
|
const decryptResult = decryptor.decryptBytes(chunk.data)
|
||||||
|
|
||||||
if (!decryptResult) {
|
if (!decryptResult) {
|
||||||
return
|
return
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,4 +3,24 @@ export type FileDownloadProgress = {
|
|||||||
encryptedBytesDownloaded: number
|
encryptedBytesDownloaded: number
|
||||||
encryptedBytesRemaining: number
|
encryptedBytesRemaining: number
|
||||||
percentComplete: 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class FileDownloader {
|
|||||||
encryptedBytesDownloaded: this.totalBytesDownloaded,
|
encryptedBytesDownloaded: this.totalBytesDownloaded,
|
||||||
encryptedBytesRemaining: encryptedSize - this.totalBytesDownloaded,
|
encryptedBytesRemaining: encryptedSize - this.totalBytesDownloaded,
|
||||||
percentComplete: (this.totalBytesDownloaded / encryptedSize) * 100.0,
|
percentComplete: (this.totalBytesDownloaded / encryptedSize) * 100.0,
|
||||||
|
source: 'network',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ export * from './Device/FileBackupMetadataFile'
|
|||||||
export * from './Device/FileBackupsConstantsV1'
|
export * from './Device/FileBackupsConstantsV1'
|
||||||
export * from './Device/FileBackupsDevice'
|
export * from './Device/FileBackupsDevice'
|
||||||
export * from './Device/FileBackupsMapping'
|
export * from './Device/FileBackupsMapping'
|
||||||
|
export * from './Service/BackupServiceInterface'
|
||||||
export * from './Service/FilesClientInterface'
|
export * from './Service/FilesClientInterface'
|
||||||
export * from './Service/ReadAndDecryptBackupFile'
|
export * from './Service/ReadAndDecryptBackupFileFileSystemAPI'
|
||||||
|
export * from './Service/ReadAndDecryptBackupFileUsingBackupService'
|
||||||
export * from './Operations/DownloadAndDecrypt'
|
export * from './Operations/DownloadAndDecrypt'
|
||||||
export * from './Operations/EncryptAndUpload'
|
export * from './Operations/EncryptAndUpload'
|
||||||
export * from './UseCase/FileDecryptor'
|
export * from './UseCase/FileDecryptor'
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"tsc": "tsc --project tsconfig.json",
|
"tsc": "tsc --project tsconfig.json",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
"lint:fix": "eslint src --ext .ts --fix",
|
"lint:fix": "eslint src --ext .ts --fix",
|
||||||
"test": "jest spec --coverage"
|
"test": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standardnotes/api": "workspace:^",
|
"@standardnotes/api": "workspace:^",
|
||||||
|
|||||||
109
packages/services/src/Domain/Backups/BackupService.spec.ts
Normal file
109
packages/services/src/Domain/Backups/BackupService.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,13 +8,17 @@ import {
|
|||||||
FileBackupsDevice,
|
FileBackupsDevice,
|
||||||
FileBackupsMapping,
|
FileBackupsMapping,
|
||||||
FileBackupRecord,
|
FileBackupRecord,
|
||||||
|
OnChunkCallback,
|
||||||
|
BackupServiceInterface,
|
||||||
} from '@standardnotes/files'
|
} from '@standardnotes/files'
|
||||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||||
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
||||||
import { AbstractService } from '../Service/AbstractService'
|
import { AbstractService } from '../Service/AbstractService'
|
||||||
import { StatusServiceInterface } from '../Status/StatusServiceInterface'
|
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 itemsObserverDisposer: () => void
|
||||||
private pendingFiles = new Set<Uuid>()
|
private pendingFiles = new Set<Uuid>()
|
||||||
private mappingCache?: FileBackupsMapping['files']
|
private mappingCache?: FileBackupsMapping['files']
|
||||||
@@ -25,6 +29,7 @@ export class FilesBackupService extends AbstractService {
|
|||||||
private encryptor: EncryptionProviderInterface,
|
private encryptor: EncryptionProviderInterface,
|
||||||
private device: FileBackupsDevice,
|
private device: FileBackupsDevice,
|
||||||
private status: StatusServiceInterface,
|
private status: StatusServiceInterface,
|
||||||
|
private crypto: PureCryptoInterface,
|
||||||
protected override internalEventBus: InternalEventBusInterface,
|
protected override internalEventBus: InternalEventBusInterface,
|
||||||
) {
|
) {
|
||||||
super(internalEventBus)
|
super(internalEventBus)
|
||||||
@@ -43,7 +48,14 @@ export class FilesBackupService extends AbstractService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override deinit() {
|
override deinit() {
|
||||||
|
super.deinit()
|
||||||
this.itemsObserverDisposer()
|
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> {
|
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||||
@@ -98,7 +110,7 @@ export class FilesBackupService extends AbstractService {
|
|||||||
return this.mappingCache ?? (await this.getBackupsMappingFromDisk())
|
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 mapping = await this.getBackupsMappingFromCache()
|
||||||
const record = mapping[file.uuid]
|
const record = mapping[file.uuid]
|
||||||
return record
|
return record
|
||||||
@@ -138,7 +150,34 @@ export class FilesBackupService extends AbstractService {
|
|||||||
this.invalidateMappingCache()
|
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'> {
|
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 messageId = this.status.addMessage(`Backing up file ${file.name}...`)
|
||||||
|
|
||||||
const encryptedFile = await this.encryptor.encryptSplitSingle({
|
const encryptedFile = await this.encryptor.encryptSplitSingle({
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
|
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
|
||||||
import { FileItem } from '@standardnotes/models'
|
import { FileItem } from '@standardnotes/models'
|
||||||
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
||||||
|
|
||||||
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
||||||
import { ChallengeServiceInterface } from '../Challenge'
|
import { ChallengeServiceInterface } from '../Challenge'
|
||||||
import { InternalEventBusInterface } from '..'
|
import { InternalEventBusInterface } from '..'
|
||||||
import { AlertService } from '../Alert/AlertService'
|
import { AlertService } from '../Alert/AlertService'
|
||||||
import { ApiServiceInterface } from '../Api/ApiServiceInterface'
|
import { ApiServiceInterface } from '../Api/ApiServiceInterface'
|
||||||
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
|
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
|
||||||
|
|
||||||
import { FileService } from './FileService'
|
import { FileService } from './FileService'
|
||||||
|
import { BackupServiceInterface } from '@standardnotes/files'
|
||||||
|
|
||||||
describe('fileService', () => {
|
describe('fileService', () => {
|
||||||
let apiService: ApiServiceInterface
|
let apiService: ApiServiceInterface
|
||||||
@@ -21,13 +20,33 @@ describe('fileService', () => {
|
|||||||
let fileService: FileService
|
let fileService: FileService
|
||||||
let encryptor: EncryptionProviderInterface
|
let encryptor: EncryptionProviderInterface
|
||||||
let internalEventBus: InternalEventBusInterface
|
let internalEventBus: InternalEventBusInterface
|
||||||
|
let backupService: BackupServiceInterface
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiService = {} as jest.Mocked<ApiServiceInterface>
|
apiService = {} as jest.Mocked<ApiServiceInterface>
|
||||||
apiService.addEventObserver = jest.fn()
|
apiService.addEventObserver = jest.fn()
|
||||||
apiService.createFileValetToken = jest.fn()
|
apiService.createFileValetToken = jest.fn()
|
||||||
apiService.downloadFile = jest.fn()
|
|
||||||
apiService.deleteFile = jest.fn().mockReturnValue({})
|
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 = {} as jest.Mocked<ItemManagerInterface>
|
||||||
itemManager.createItem = jest.fn()
|
itemManager.createItem = jest.fn()
|
||||||
@@ -52,6 +71,10 @@ describe('fileService', () => {
|
|||||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||||
internalEventBus.publish = jest.fn()
|
internalEventBus.publish = jest.fn()
|
||||||
|
|
||||||
|
backupService = {} as jest.Mocked<BackupServiceInterface>
|
||||||
|
backupService.readEncryptedFileFromBackup = jest.fn()
|
||||||
|
backupService.getFileBackupInfo = jest.fn()
|
||||||
|
|
||||||
fileService = new FileService(
|
fileService = new FileService(
|
||||||
apiService,
|
apiService,
|
||||||
itemManager,
|
itemManager,
|
||||||
@@ -61,6 +84,7 @@ describe('fileService', () => {
|
|||||||
alertService,
|
alertService,
|
||||||
crypto,
|
crypto,
|
||||||
internalEventBus,
|
internalEventBus,
|
||||||
|
backupService,
|
||||||
)
|
)
|
||||||
|
|
||||||
crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({
|
crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({
|
||||||
@@ -110,6 +134,8 @@ describe('fileService', () => {
|
|||||||
decryptedSize: 100_000,
|
decryptedSize: 100_000,
|
||||||
} as jest.Mocked<FileItem>
|
} as jest.Mocked<FileItem>
|
||||||
|
|
||||||
|
apiService.downloadFile = jest.fn()
|
||||||
|
|
||||||
await fileService.downloadFile(file, async () => {
|
await fileService.downloadFile(file, async () => {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
})
|
})
|
||||||
@@ -118,4 +144,42 @@ describe('fileService', () => {
|
|||||||
|
|
||||||
expect(fileService['encryptedCache'].get(file.uuid)).toBeFalsy()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
FileDecryptor,
|
FileDecryptor,
|
||||||
FileDownloadProgress,
|
FileDownloadProgress,
|
||||||
FilesClientInterface,
|
FilesClientInterface,
|
||||||
readAndDecryptBackupFile,
|
readAndDecryptBackupFileUsingFileSystemAPI,
|
||||||
FilesApiInterface,
|
FilesApiInterface,
|
||||||
FileBackupsConstantsV1,
|
FileBackupsConstantsV1,
|
||||||
FileBackupMetadataFile,
|
FileBackupMetadataFile,
|
||||||
@@ -30,8 +30,9 @@ import {
|
|||||||
DecryptedBytes,
|
DecryptedBytes,
|
||||||
OrderedByteChunker,
|
OrderedByteChunker,
|
||||||
FileMemoryCache,
|
FileMemoryCache,
|
||||||
|
readAndDecryptBackupFileUsingBackupService,
|
||||||
|
BackupServiceInterface,
|
||||||
} from '@standardnotes/files'
|
} from '@standardnotes/files'
|
||||||
|
|
||||||
import { AlertService } from '../Alert/AlertService'
|
import { AlertService } from '../Alert/AlertService'
|
||||||
import { ChallengeServiceInterface } from '../Challenge'
|
import { ChallengeServiceInterface } from '../Challenge'
|
||||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||||
@@ -39,6 +40,7 @@ import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
|||||||
import { AbstractService } from '../Service/AbstractService'
|
import { AbstractService } from '../Service/AbstractService'
|
||||||
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
|
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
|
||||||
import { DecryptItemsKeyWithUserFallback } from '../Encryption/Functions'
|
import { DecryptItemsKeyWithUserFallback } from '../Encryption/Functions'
|
||||||
|
import { log, LoggingDomain } from '../Logging'
|
||||||
|
|
||||||
const OneHundredMb = 100 * 1_000_000
|
const OneHundredMb = 100 * 1_000_000
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ export class FileService extends AbstractService implements FilesClientInterface
|
|||||||
private alertService: AlertService,
|
private alertService: AlertService,
|
||||||
private crypto: PureCryptoInterface,
|
private crypto: PureCryptoInterface,
|
||||||
protected override internalEventBus: InternalEventBusInterface,
|
protected override internalEventBus: InternalEventBusInterface,
|
||||||
|
private backupsService?: BackupServiceInterface,
|
||||||
) {
|
) {
|
||||||
super(internalEventBus)
|
super(internalEventBus)
|
||||||
}
|
}
|
||||||
@@ -158,8 +161,8 @@ export class FileService extends AbstractService implements FilesClientInterface
|
|||||||
|
|
||||||
let decryptedAggregate = new Uint8Array()
|
let decryptedAggregate = new Uint8Array()
|
||||||
|
|
||||||
const orderedChunker = new OrderedByteChunker(file.encryptedChunkSizes, async (encryptedBytes) => {
|
const orderedChunker = new OrderedByteChunker(file.encryptedChunkSizes, 'memcache', async (chunk) => {
|
||||||
const decryptedBytes = decryptOperation.decryptBytes(encryptedBytes)
|
const decryptedBytes = decryptOperation.decryptBytes(chunk.data)
|
||||||
|
|
||||||
if (decryptedBytes) {
|
if (decryptedBytes) {
|
||||||
decryptedAggregate = new Uint8Array([...decryptedAggregate, ...decryptedBytes.decryptedBytes])
|
decryptedAggregate = new Uint8Array([...decryptedAggregate, ...decryptedBytes.decryptedBytes])
|
||||||
@@ -173,36 +176,60 @@ export class FileService extends AbstractService implements FilesClientInterface
|
|||||||
|
|
||||||
public async downloadFile(
|
public async downloadFile(
|
||||||
file: FileItem,
|
file: FileItem,
|
||||||
onDecryptedBytes: (decryptedBytes: Uint8Array, progress?: FileDownloadProgress) => Promise<void>,
|
onDecryptedBytes: (decryptedBytes: Uint8Array, progress: FileDownloadProgress) => Promise<void>,
|
||||||
): Promise<ClientDisplayableError | undefined> {
|
): Promise<ClientDisplayableError | undefined> {
|
||||||
const cachedBytes = this.encryptedCache.get(file.uuid)
|
const cachedBytes = this.encryptedCache.get(file.uuid)
|
||||||
|
|
||||||
if (cachedBytes) {
|
if (cachedBytes) {
|
||||||
const decryptedBytes = await this.decryptCachedEntry(file, 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
|
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> => {
|
return onDecryptedBytes(chunk.data, chunk.progress)
|
||||||
if (addToCache) {
|
})
|
||||||
cacheEntryAggregate = new Uint8Array([...cacheEntryAggregate, ...encrypted.encryptedBytes])
|
|
||||||
|
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) {
|
return result.error
|
||||||
this.encryptedCache.add(file.uuid, { encryptedBytes: cacheEntryAggregate })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined> {
|
public async deleteFile(file: FileItem): Promise<ClientDisplayableError | undefined> {
|
||||||
@@ -294,9 +321,15 @@ export class FileService extends AbstractService implements FilesClientInterface
|
|||||||
return destinationFileHandle
|
return destinationFileHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await readAndDecryptBackupFile(fileHandle, file, fileSystem, this.crypto, async (decryptedBytes) => {
|
const result = await readAndDecryptBackupFileUsingFileSystemAPI(
|
||||||
await fileSystem.saveBytes(destinationFileHandle, decryptedBytes)
|
fileHandle,
|
||||||
})
|
file,
|
||||||
|
fileSystem,
|
||||||
|
this.crypto,
|
||||||
|
async (decryptedBytes) => {
|
||||||
|
await fileSystem.saveBytes(destinationFileHandle, decryptedBytes)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await fileSystem.closeFileWriteStream(destinationFileHandle)
|
await fileSystem.closeFileWriteStream(destinationFileHandle)
|
||||||
|
|
||||||
@@ -310,9 +343,15 @@ export class FileService extends AbstractService implements FilesClientInterface
|
|||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
let bytes = new Uint8Array()
|
let bytes = new Uint8Array()
|
||||||
|
|
||||||
await readAndDecryptBackupFile(fileHandle, file, fileSystem, this.crypto, async (decryptedBytes) => {
|
await readAndDecryptBackupFileUsingFileSystemAPI(
|
||||||
bytes = new Uint8Array([...bytes, ...decryptedBytes])
|
fileHandle,
|
||||||
})
|
file,
|
||||||
|
fileSystem,
|
||||||
|
this.crypto,
|
||||||
|
async (decryptedBytes) => {
|
||||||
|
bytes = new Uint8Array([...bytes, ...decryptedBytes])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return bytes
|
return bytes
|
||||||
}
|
}
|
||||||
|
|||||||
33
packages/services/src/Domain/Logging.ts
Normal file
33
packages/services/src/Domain/Logging.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ import {
|
|||||||
SessionStrings,
|
SessionStrings,
|
||||||
AccountEvent,
|
AccountEvent,
|
||||||
} from '@standardnotes/services'
|
} from '@standardnotes/services'
|
||||||
import { FilesClientInterface } from '@standardnotes/files'
|
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
|
||||||
import { ComputePrivateUsername } from '@standardnotes/encryption'
|
import { ComputePrivateUsername } from '@standardnotes/encryption'
|
||||||
import { useBoolean } from '@standardnotes/utils'
|
import { useBoolean } from '@standardnotes/utils'
|
||||||
import {
|
import {
|
||||||
@@ -275,7 +275,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
return this.statusService
|
return this.statusService
|
||||||
}
|
}
|
||||||
|
|
||||||
public get fileBackups(): FilesBackupService | undefined {
|
public get fileBackups(): BackupServiceInterface | undefined {
|
||||||
return this.filesBackupService
|
return this.filesBackupService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,16 +1113,17 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
this.createComponentManager()
|
this.createComponentManager()
|
||||||
this.createMigrationService()
|
this.createMigrationService()
|
||||||
this.createMfaService()
|
this.createMfaService()
|
||||||
|
|
||||||
|
this.createStatusService()
|
||||||
|
if (isDesktopDevice(this.deviceInterface)) {
|
||||||
|
this.createFilesBackupService(this.deviceInterface)
|
||||||
|
}
|
||||||
this.createFileService()
|
this.createFileService()
|
||||||
|
|
||||||
this.createIntegrityService()
|
this.createIntegrityService()
|
||||||
this.createMutatorService()
|
this.createMutatorService()
|
||||||
this.createListedService()
|
this.createListedService()
|
||||||
this.createActionsManager()
|
this.createActionsManager()
|
||||||
this.createStatusService()
|
|
||||||
|
|
||||||
if (isDesktopDevice(this.deviceInterface)) {
|
|
||||||
this.createFilesBackupService(this.deviceInterface)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearServices() {
|
private clearServices() {
|
||||||
@@ -1210,6 +1211,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
this.alertService,
|
this.alertService,
|
||||||
this.options.crypto,
|
this.options.crypto,
|
||||||
this.internalEventBus,
|
this.internalEventBus,
|
||||||
|
this.fileBackups,
|
||||||
)
|
)
|
||||||
|
|
||||||
this.services.push(this.fileService)
|
this.services.push(this.fileService)
|
||||||
@@ -1670,6 +1672,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
|||||||
this.protocolService,
|
this.protocolService,
|
||||||
device,
|
device,
|
||||||
this.statusService,
|
this.statusService,
|
||||||
|
this.options.crypto,
|
||||||
this.internalEventBus,
|
this.internalEventBus,
|
||||||
)
|
)
|
||||||
this.services.push(this.filesBackupService)
|
this.services.push(this.filesBackupService)
|
||||||
|
|||||||
@@ -655,11 +655,15 @@ export function secondHalfOfString(string: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function log(namespace: string, ...args: any[]): void {
|
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 date = new Date()
|
||||||
const timeString = `${date.toLocaleTimeString().replace(' PM', '').replace(' AM', '')}.${date.getMilliseconds()}`
|
const timeString = `${date.toLocaleTimeString().replace(' PM', '').replace(' AM', '')}.${date.getMilliseconds()}`
|
||||||
customLog(
|
customLog(
|
||||||
`%c${namespace}%c${timeString}`,
|
`%c${namespace}%c${timeString}`,
|
||||||
'color: black; font-weight: bold; margin-right: 4px',
|
`color: ${namespaceColor}; font-weight: bold; margin-right: 4px`,
|
||||||
'color: gray',
|
'color: gray',
|
||||||
...args,
|
...args,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ export {
|
|||||||
FileBackupsMapping,
|
FileBackupsMapping,
|
||||||
FileBackupsDevice,
|
FileBackupsDevice,
|
||||||
FileBackupRecord,
|
FileBackupRecord,
|
||||||
|
FileBackupReadToken,
|
||||||
|
FileBackupReadChunkResponse,
|
||||||
|
FileDownloadProgress,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { WebApplication } from '@/Application/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { concatenateUint8Arrays } from '@/Utils'
|
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 { useEffect, useMemo, useState } from 'react'
|
||||||
import Spinner from '@/Components/Spinner/Spinner'
|
import Spinner from '@/Components/Spinner/Spinner'
|
||||||
import FilePreviewError from './FilePreviewError'
|
import FilePreviewError from './FilePreviewError'
|
||||||
@@ -24,7 +29,7 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
|
|||||||
}, [file.mimeType])
|
}, [file.mimeType])
|
||||||
|
|
||||||
const [isDownloading, setIsDownloading] = useState(true)
|
const [isDownloading, setIsDownloading] = useState(true)
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
const [downloadProgress, setDownloadProgress] = useState<FileDownloadProgress | undefined>()
|
||||||
const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>()
|
const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,7 +51,7 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFilePreviewable || !isAuthorized) {
|
if (!isFilePreviewable || !isAuthorized) {
|
||||||
setIsDownloading(false)
|
setIsDownloading(false)
|
||||||
setDownloadProgress(0)
|
setDownloadProgress(undefined)
|
||||||
setDownloadedBytes(undefined)
|
setDownloadedBytes(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,15 +65,18 @@ const FilePreview = ({ file, application, isEmbeddedInSuper = false, imageZoomLe
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const chunks: Uint8Array[] = []
|
const chunks: Uint8Array[] = []
|
||||||
setDownloadProgress(0)
|
setDownloadProgress(undefined)
|
||||||
await application.files.downloadFile(file, async (decryptedChunk, progress) => {
|
const error = await application.files.downloadFile(file, async (decryptedChunk, progress) => {
|
||||||
chunks.push(decryptedChunk)
|
chunks.push(decryptedChunk)
|
||||||
if (progress) {
|
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} 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 flex-grow flex-col items-center justify-center">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Spinner className="mr-3 h-5 w-5" />
|
<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>
|
</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>
|
</div>
|
||||||
) : downloadedBytes ? (
|
) : downloadedBytes ? (
|
||||||
<PreviewComponent
|
<PreviewComponent
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ const FileBackupsDesktop = ({ application, backupsService }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</PreferencesSegment>
|
</PreferencesSegment>
|
||||||
|
|
||||||
<HorizontalSeparator classes="my-4" />
|
|
||||||
|
|
||||||
{backupsEnabled && (
|
{backupsEnabled && (
|
||||||
<>
|
<>
|
||||||
|
<HorizontalSeparator classes="my-4" />
|
||||||
|
|
||||||
<PreferencesSegment>
|
<PreferencesSegment>
|
||||||
<>
|
<>
|
||||||
<Text className="mb-3">
|
<Text className="mb-3">
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
FileDownloadProgress,
|
||||||
|
fileProgressToHumanReadableString,
|
||||||
|
OnChunkCallbackNoProgress,
|
||||||
|
} from '@standardnotes/files'
|
||||||
import { FilePreviewModalController } from './FilePreviewModalController'
|
import { FilePreviewModalController } from './FilePreviewModalController'
|
||||||
import {
|
import {
|
||||||
PopoverFileItemAction,
|
PopoverFileItemAction,
|
||||||
@@ -260,6 +265,8 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
|||||||
|
|
||||||
const decryptedBytesArray: Uint8Array[] = []
|
const decryptedBytesArray: Uint8Array[] = []
|
||||||
|
|
||||||
|
let lastProgress: FileDownloadProgress | undefined
|
||||||
|
|
||||||
const result = await this.application.files.downloadFile(file, async (decryptedBytes, progress) => {
|
const result = await this.application.files.downloadFile(file, async (decryptedBytes, progress) => {
|
||||||
if (isUsingStreamingSaver) {
|
if (isUsingStreamingSaver) {
|
||||||
await saver.pushBytes(decryptedBytes)
|
await saver.pushBytes(decryptedBytes)
|
||||||
@@ -267,14 +274,14 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
|||||||
decryptedBytesArray.push(decryptedBytes)
|
decryptedBytesArray.push(decryptedBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress) {
|
const progressPercent = Math.floor(progress.percentComplete)
|
||||||
const progressPercent = Math.floor(progress.percentComplete)
|
|
||||||
|
|
||||||
updateToast(downloadingToastId, {
|
updateToast(downloadingToastId, {
|
||||||
message: `Downloading file "${file.name}" (${progressPercent}%)`,
|
message: fileProgressToHumanReadableString(progress, file.name, { showPercent: true }),
|
||||||
progress: progressPercent,
|
progress: progressPercent,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
lastProgress = progress
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result instanceof ClientDisplayableError) {
|
if (result instanceof ClientDisplayableError) {
|
||||||
@@ -293,7 +300,9 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
|||||||
|
|
||||||
addToast({
|
addToast({
|
||||||
type: ToastType.Success,
|
type: ToastType.Success,
|
||||||
message: 'Successfully downloaded file',
|
message: `Successfully downloaded file${
|
||||||
|
lastProgress && lastProgress.source === 'local' ? ' from local backup' : ''
|
||||||
|
}`,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -364,13 +373,13 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
|||||||
progress: initialProgress,
|
progress: initialProgress,
|
||||||
})
|
})
|
||||||
|
|
||||||
const onChunk = async (chunk: Uint8Array, index: number, isLast: boolean) => {
|
const onChunk: OnChunkCallbackNoProgress = async ({ data, index, isLast }) => {
|
||||||
await this.application.files.pushBytesForUpload(operation, chunk, 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, {
|
updateToast(toastId, {
|
||||||
message: `Uploading file "${file.name}" (${progress}%)`,
|
message: `Uploading file "${file.name}" (${percentComplete}%)`,
|
||||||
progress,
|
progress: percentComplete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user