feat: add desktop repo (#1071)
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { FileBackupsDevice, FileBackupsMapping } from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { AppState } from 'app/application'
|
||||
import { StoreKeys } from '../Store'
|
||||
import {
|
||||
ensureDirectoryExists,
|
||||
moveDirContents,
|
||||
openDirectoryPicker,
|
||||
readJSONFile,
|
||||
writeFile,
|
||||
writeJSONFile,
|
||||
} from '../Utils/FileUtils'
|
||||
import { FileDownloader } from './FileDownloader'
|
||||
import { shell } from 'electron'
|
||||
|
||||
export const FileBackupsConstantsV1 = {
|
||||
Version: '1.0.0',
|
||||
MetadataFileName: 'metadata.sn.json',
|
||||
BinaryFileName: 'file.encrypted',
|
||||
}
|
||||
|
||||
export class FilesBackupManager implements FileBackupsDevice {
|
||||
constructor(private appState: AppState) {}
|
||||
|
||||
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsEnabled))
|
||||
}
|
||||
|
||||
public async enableFilesBackups(): Promise<void> {
|
||||
const currentLocation = await this.getFilesBackupsLocation()
|
||||
|
||||
if (!currentLocation) {
|
||||
const result = await this.changeFilesBackupsLocation()
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.appState.store.set(StoreKeys.FileBackupsEnabled, true)
|
||||
|
||||
const mapping = this.getMappingFileFromDisk()
|
||||
|
||||
if (!mapping) {
|
||||
await this.saveFilesBackupsMappingFile(this.defaultMappingFileValue())
|
||||
}
|
||||
}
|
||||
|
||||
public disableFilesBackups(): Promise<void> {
|
||||
this.appState.store.set(StoreKeys.FileBackupsEnabled, false)
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public async changeFilesBackupsLocation(): Promise<string | undefined> {
|
||||
const newPath = await openDirectoryPicker()
|
||||
|
||||
if (!newPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const oldPath = await this.getFilesBackupsLocation()
|
||||
|
||||
if (oldPath) {
|
||||
await moveDirContents(oldPath, newPath)
|
||||
}
|
||||
|
||||
this.appState.store.set(StoreKeys.FileBackupsLocation, newPath)
|
||||
|
||||
return newPath
|
||||
}
|
||||
|
||||
public getFilesBackupsLocation(): Promise<string> {
|
||||
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsLocation))
|
||||
}
|
||||
|
||||
private getMappingFileLocation(): string {
|
||||
const base = this.appState.store.get(StoreKeys.FileBackupsLocation)
|
||||
return `${base}/info.json`
|
||||
}
|
||||
|
||||
private async getMappingFileFromDisk(): Promise<FileBackupsMapping | undefined> {
|
||||
return readJSONFile<FileBackupsMapping>(this.getMappingFileLocation())
|
||||
}
|
||||
|
||||
private defaultMappingFileValue(): FileBackupsMapping {
|
||||
return { version: FileBackupsConstantsV1.Version, files: {} }
|
||||
}
|
||||
|
||||
async getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
|
||||
const data = await this.getMappingFileFromDisk()
|
||||
|
||||
if (!data) {
|
||||
return this.defaultMappingFileValue()
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async openFilesBackupsLocation(): Promise<void> {
|
||||
const location = await this.getFilesBackupsLocation()
|
||||
|
||||
void shell.openPath(location)
|
||||
}
|
||||
|
||||
async saveFilesBackupsMappingFile(file: FileBackupsMapping): Promise<'success' | 'failed'> {
|
||||
await writeJSONFile(this.getMappingFileLocation(), file)
|
||||
|
||||
return 'success'
|
||||
}
|
||||
|
||||
async saveFilesBackupsFile(
|
||||
uuid: string,
|
||||
metaFile: string,
|
||||
downloadRequest: {
|
||||
chunkSizes: number[]
|
||||
valetToken: string
|
||||
url: string
|
||||
},
|
||||
): Promise<'success' | 'failed'> {
|
||||
const backupsDir = await this.getFilesBackupsLocation()
|
||||
|
||||
const fileDir = `${backupsDir}/${uuid}`
|
||||
const metaFilePath = `${fileDir}/${FileBackupsConstantsV1.MetadataFileName}`
|
||||
const binaryPath = `${fileDir}/${FileBackupsConstantsV1.BinaryFileName}`
|
||||
|
||||
await ensureDirectoryExists(fileDir)
|
||||
|
||||
await writeFile(metaFilePath, metaFile)
|
||||
|
||||
const downloader = new FileDownloader(
|
||||
downloadRequest.chunkSizes,
|
||||
downloadRequest.valetToken,
|
||||
downloadRequest.url,
|
||||
binaryPath,
|
||||
)
|
||||
|
||||
const result = await downloader.run()
|
||||
|
||||
if (result === 'success') {
|
||||
const mapping = await this.getFilesBackupsMappingFile()
|
||||
|
||||
mapping.files[uuid] = {
|
||||
backedUpOn: new Date(),
|
||||
absolutePath: fileDir,
|
||||
relativePath: uuid,
|
||||
metadataFileName: FileBackupsConstantsV1.MetadataFileName,
|
||||
binaryFileName: FileBackupsConstantsV1.BinaryFileName,
|
||||
version: FileBackupsConstantsV1.Version,
|
||||
}
|
||||
|
||||
await this.saveFilesBackupsMappingFile(mapping)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { WriteStream, createWriteStream } from 'fs'
|
||||
import { downloadData } from './FileNetworking'
|
||||
|
||||
export class FileDownloader {
|
||||
writeStream: WriteStream
|
||||
|
||||
constructor(private chunkSizes: number[], private valetToken: string, private url: string, filePath: string) {
|
||||
this.writeStream = createWriteStream(filePath, { flags: 'a' })
|
||||
}
|
||||
|
||||
public async run(): Promise<'success' | 'failed'> {
|
||||
const result = await this.downloadChunk(0, 0)
|
||||
|
||||
this.writeStream.close()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async downloadChunk(chunkIndex = 0, contentRangeStart: number): Promise<'success' | 'failed'> {
|
||||
const pullChunkSize = this.chunkSizes[chunkIndex]
|
||||
|
||||
const headers = {
|
||||
'x-valet-token': this.valetToken,
|
||||
'x-chunk-size': pullChunkSize.toString(),
|
||||
range: `bytes=${contentRangeStart}-`,
|
||||
}
|
||||
|
||||
const response = await downloadData(this.writeStream, this.url, headers)
|
||||
|
||||
if (!String(response.status).startsWith('2')) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const contentRangeHeader = response.headers['content-range'] as string
|
||||
if (!contentRangeHeader) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const matches = contentRangeHeader.match(/(^[a-zA-Z][\w]*)\s+(\d+)\s?-\s?(\d+)?\s?\/?\s?(\d+|\*)?/)
|
||||
if (!matches || matches.length !== 5) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
const rangeStart = +matches[2]
|
||||
const rangeEnd = +matches[3]
|
||||
const totalSize = +matches[4]
|
||||
|
||||
if (rangeEnd < totalSize - 1) {
|
||||
return this.downloadChunk(++chunkIndex, rangeStart + pullChunkSize)
|
||||
}
|
||||
|
||||
return 'success'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { WriteStream } from 'fs'
|
||||
import axios, { AxiosResponseHeaders, AxiosRequestHeaders } from 'axios'
|
||||
|
||||
export async function downloadData(
|
||||
writeStream: WriteStream,
|
||||
url: string,
|
||||
headers: AxiosRequestHeaders,
|
||||
): Promise<{
|
||||
headers: AxiosResponseHeaders
|
||||
status: number
|
||||
}> {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: headers,
|
||||
})
|
||||
|
||||
if (String(response.status).startsWith('2')) {
|
||||
writeStream.write(response.data)
|
||||
}
|
||||
|
||||
return { headers: response.headers, status: response.status }
|
||||
}
|
||||
Reference in New Issue
Block a user