Skip to content
This repository was archived by the owner on Jun 20, 2022. It is now read-only.

Commit 55eea5f

Browse files
authored
feat: file backups (#870)
1 parent bce9882 commit 55eea5f

File tree

16 files changed

+613
-21
lines changed

16 files changed

+613
-21
lines changed

app/javascripts/Main/Backups/BackupsManager.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import { AppMessageType, MessageType } from '../../../../test/TestIpcMessage'
55
import { AppState } from '../../../application'
66
import { MessageToWebApp } from '../../Shared/IpcMessages'
77
import { BackupsManagerInterface } from './BackupsManagerInterface'
8-
import { deleteDir, deleteDirContents, ensureDirectoryExists, FileDoesNotExist, moveFiles } from '../Utils/FileUtils'
8+
import {
9+
deleteDir,
10+
deleteDirContents,
11+
ensureDirectoryExists,
12+
FileDoesNotExist,
13+
moveFiles,
14+
openDirectoryPicker,
15+
} from '../Utils/FileUtils'
916
import { Paths } from '../Types/Paths'
1017
import { StoreKeys } from '../Store'
1118
import { backups as str } from '../Strings'
@@ -194,12 +201,14 @@ export function createBackupsManager(webContents: WebContents, appState: AppStat
194201
await deleteDirContents(backupsLocation)
195202
return copyDecryptScript(backupsLocation)
196203
},
204+
197205
async changeBackupsLocation() {
198-
const result = await dialog.showOpenDialog({
199-
properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'],
200-
})
201-
if (result.filePaths.length === 0) return
202-
const path = result.filePaths[0]
206+
const path = await openDirectoryPicker()
207+
208+
if (!path) {
209+
return
210+
}
211+
203212
try {
204213
await setBackupsLocation(path)
205214
performBackup()
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { FileBackupsDevice, FileBackupsMapping } from '@web/Device/DesktopSnjsExports'
2+
import { AppState } from 'app/application'
3+
import { StoreKeys } from '../Store'
4+
import {
5+
ensureDirectoryExists,
6+
moveDirContents,
7+
openDirectoryPicker,
8+
readJSONFile,
9+
writeFile,
10+
writeJSONFile,
11+
} from '../Utils/FileUtils'
12+
import { FileDownloader } from './FileDownloader'
13+
import { shell } from 'electron'
14+
15+
export const FileBackupsConstantsV1 = {
16+
Version: '1.0.0',
17+
MetadataFileName: 'metadata.sn.json',
18+
BinaryFileName: 'file.encrypted',
19+
}
20+
21+
export class FilesBackupManager implements FileBackupsDevice {
22+
constructor(private appState: AppState) {}
23+
24+
public isFilesBackupsEnabled(): Promise<boolean> {
25+
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsEnabled))
26+
}
27+
28+
public async enableFilesBackups(): Promise<void> {
29+
const currentLocation = await this.getFilesBackupsLocation()
30+
31+
if (!currentLocation) {
32+
const result = await this.changeFilesBackupsLocation()
33+
34+
if (!result) {
35+
return
36+
}
37+
}
38+
39+
this.appState.store.set(StoreKeys.FileBackupsEnabled, true)
40+
41+
const mapping = this.getMappingFileFromDisk()
42+
43+
if (!mapping) {
44+
await this.saveFilesBackupsMappingFile(this.defaultMappingFileValue())
45+
}
46+
}
47+
48+
public disableFilesBackups(): Promise<void> {
49+
this.appState.store.set(StoreKeys.FileBackupsEnabled, false)
50+
51+
return Promise.resolve()
52+
}
53+
54+
public async changeFilesBackupsLocation(): Promise<string | undefined> {
55+
const newPath = await openDirectoryPicker()
56+
57+
if (!newPath) {
58+
return undefined
59+
}
60+
61+
const oldPath = await this.getFilesBackupsLocation()
62+
63+
if (oldPath) {
64+
await moveDirContents(oldPath, newPath)
65+
}
66+
67+
this.appState.store.set(StoreKeys.FileBackupsLocation, newPath)
68+
69+
return newPath
70+
}
71+
72+
public getFilesBackupsLocation(): Promise<string> {
73+
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsLocation))
74+
}
75+
76+
private getMappingFileLocation(): string {
77+
const base = this.appState.store.get(StoreKeys.FileBackupsLocation)
78+
return `${base}/info.json`
79+
}
80+
81+
private async getMappingFileFromDisk(): Promise<FileBackupsMapping | undefined> {
82+
return readJSONFile<FileBackupsMapping>(this.getMappingFileLocation())
83+
}
84+
85+
private defaultMappingFileValue(): FileBackupsMapping {
86+
return { version: FileBackupsConstantsV1.Version, files: {} }
87+
}
88+
89+
async getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
90+
const data = await this.getMappingFileFromDisk()
91+
92+
if (!data) {
93+
return this.defaultMappingFileValue()
94+
}
95+
96+
return data
97+
}
98+
99+
async openFilesBackupsLocation(): Promise<void> {
100+
const location = await this.getFilesBackupsLocation()
101+
102+
shell.openPath(location)
103+
}
104+
105+
async saveFilesBackupsMappingFile(file: FileBackupsMapping): Promise<'success' | 'failed'> {
106+
await writeJSONFile(this.getMappingFileLocation(), file)
107+
108+
return 'success'
109+
}
110+
111+
async saveFilesBackupsFile(
112+
uuid: string,
113+
metaFile: string,
114+
downloadRequest: {
115+
chunkSizes: number[]
116+
valetToken: string
117+
url: string
118+
},
119+
): Promise<'success' | 'failed'> {
120+
const backupsDir = await this.getFilesBackupsLocation()
121+
122+
const fileDir = `${backupsDir}/${uuid}`
123+
const metaFilePath = `${fileDir}/${FileBackupsConstantsV1.MetadataFileName}`
124+
const binaryPath = `${fileDir}/${FileBackupsConstantsV1.BinaryFileName}`
125+
126+
await ensureDirectoryExists(fileDir)
127+
128+
await writeFile(metaFilePath, metaFile)
129+
130+
const downloader = new FileDownloader(
131+
downloadRequest.chunkSizes,
132+
downloadRequest.valetToken,
133+
downloadRequest.url,
134+
binaryPath,
135+
)
136+
137+
const result = await downloader.run()
138+
139+
if (result === 'success') {
140+
const mapping = await this.getFilesBackupsMappingFile()
141+
142+
mapping.files[uuid] = {
143+
backedUpOn: new Date(),
144+
absolutePath: fileDir,
145+
relativePath: uuid,
146+
metadataFileName: FileBackupsConstantsV1.MetadataFileName,
147+
binaryFileName: FileBackupsConstantsV1.BinaryFileName,
148+
version: FileBackupsConstantsV1.Version,
149+
}
150+
151+
await this.saveFilesBackupsMappingFile(mapping)
152+
}
153+
154+
return result
155+
}
156+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { WriteStream, createWriteStream } from 'fs'
2+
import { downloadData } from './FileNetworking'
3+
4+
export class FileDownloader {
5+
writeStream: WriteStream
6+
7+
constructor(private chunkSizes: number[], private valetToken: string, private url: string, filePath: string) {
8+
this.writeStream = createWriteStream(filePath, { flags: 'a' })
9+
}
10+
11+
public async run(): Promise<'success' | 'failed'> {
12+
const result = await this.downloadChunk(0, 0)
13+
14+
this.writeStream.close()
15+
16+
return result
17+
}
18+
19+
private async downloadChunk(chunkIndex = 0, contentRangeStart: number): Promise<'success' | 'failed'> {
20+
const pullChunkSize = this.chunkSizes[chunkIndex]
21+
22+
const headers = {
23+
'x-valet-token': this.valetToken,
24+
'x-chunk-size': pullChunkSize.toString(),
25+
range: `bytes=${contentRangeStart}-`,
26+
}
27+
28+
const response = await downloadData(this.writeStream, this.url, headers)
29+
30+
if (!String(response.status).startsWith('2')) {
31+
return 'failed'
32+
}
33+
34+
const contentRangeHeader = response.headers['content-range'] as string
35+
if (!contentRangeHeader) {
36+
return 'failed'
37+
}
38+
39+
const matches = contentRangeHeader.match(/(^[a-zA-Z][\w]*)\s+(\d+)\s?-\s?(\d+)?\s?\/?\s?(\d+|\*)?/)
40+
if (!matches || matches.length !== 5) {
41+
return 'failed'
42+
}
43+
44+
const rangeStart = +matches[2]
45+
const rangeEnd = +matches[3]
46+
const totalSize = +matches[4]
47+
48+
if (rangeEnd < totalSize - 1) {
49+
return this.downloadChunk(++chunkIndex, rangeStart + pullChunkSize)
50+
}
51+
52+
return 'success'
53+
}
54+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { WriteStream } from 'fs'
2+
import axios, { AxiosResponseHeaders, AxiosRequestHeaders } from 'axios'
3+
4+
export async function downloadData(
5+
writeStream: WriteStream,
6+
url: string,
7+
headers: AxiosRequestHeaders,
8+
): Promise<{
9+
headers: AxiosResponseHeaders
10+
status: number
11+
}> {
12+
const response = await axios.get(url, {
13+
responseType: 'arraybuffer',
14+
headers: headers,
15+
})
16+
17+
if (String(response.status).startsWith('2')) {
18+
writeStream.write(response.data)
19+
}
20+
21+
return { headers: response.headers, status: response.status }
22+
}

app/javascripts/Main/Packages/PackageManager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ class MappingFileHandler {
3434
let mapping: MappingFile
3535

3636
try {
37-
mapping = await readJSONFile<MappingFile>(Paths.extensionsMappingJson)
37+
const result = await readJSONFile<MappingFile>(Paths.extensionsMappingJson)
38+
mapping = result || {}
3839
} catch (error: any) {
3940
/**
4041
* Mapping file might be absent (first start, corrupted data)
@@ -82,6 +83,9 @@ class MappingFileHandler {
8283
const paths = pathsForComponent(component)
8384
const packagePath = path.join(paths.absolutePath, 'package.json')
8485
const response = await readJSONFile<{ version: string }>(packagePath)
86+
if (!response) {
87+
return ''
88+
}
8589
this.set(component.uuid, paths.relativePath, response.version)
8690
return response.version
8791
}

app/javascripts/Main/Remote/RemoteBridge.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { PackageManagerInterface, Component } from '../Packages/PackageManagerIn
1111
import { SearchManagerInterface } from '../Search/SearchManagerInterface'
1212
import { RemoteDataInterface } from './DataInterface'
1313
import { MenuManagerInterface } from '../Menus/MenuManagerInterface'
14+
import { FileBackupsDevice, FileBackupsMapping } from '@web/Device/DesktopSnjsExports'
1415

1516
/**
1617
* Read https://github.com/electron/remote to understand how electron/remote works.
@@ -25,6 +26,7 @@ export class RemoteBridge implements CrossProcessBridge {
2526
private search: SearchManagerInterface,
2627
private data: RemoteDataInterface,
2728
private menus: MenuManagerInterface,
29+
private fileBackups: FileBackupsDevice,
2830
) {}
2931

3032
get exposableValue(): CrossProcessBridge {
@@ -53,6 +55,14 @@ export class RemoteBridge implements CrossProcessBridge {
5355
onSearch: this.onSearch.bind(this),
5456
onInitialDataLoad: this.onInitialDataLoad.bind(this),
5557
destroyAllData: this.destroyAllData.bind(this),
58+
getFilesBackupsMappingFile: this.getFilesBackupsMappingFile.bind(this),
59+
saveFilesBackupsFile: this.saveFilesBackupsFile.bind(this),
60+
isFilesBackupsEnabled: this.isFilesBackupsEnabled.bind(this),
61+
enableFilesBackups: this.enableFilesBackups.bind(this),
62+
disableFilesBackups: this.disableFilesBackups.bind(this),
63+
changeFilesBackupsLocation: this.changeFilesBackupsLocation.bind(this),
64+
getFilesBackupsLocation: this.getFilesBackupsLocation.bind(this),
65+
openFilesBackupsLocation: this.openFilesBackupsLocation.bind(this),
5666
}
5767
}
5868

@@ -151,4 +161,44 @@ export class RemoteBridge implements CrossProcessBridge {
151161
displayAppMenu() {
152162
this.menus.popupMenu()
153163
}
164+
165+
getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
166+
return this.fileBackups.getFilesBackupsMappingFile()
167+
}
168+
169+
saveFilesBackupsFile(
170+
uuid: string,
171+
metaFile: string,
172+
downloadRequest: {
173+
chunkSizes: number[]
174+
valetToken: string
175+
url: string
176+
},
177+
): Promise<'success' | 'failed'> {
178+
return this.fileBackups.saveFilesBackupsFile(uuid, metaFile, downloadRequest)
179+
}
180+
181+
public isFilesBackupsEnabled(): Promise<boolean> {
182+
return this.fileBackups.isFilesBackupsEnabled()
183+
}
184+
185+
public enableFilesBackups(): Promise<void> {
186+
return this.fileBackups.enableFilesBackups()
187+
}
188+
189+
public disableFilesBackups(): Promise<void> {
190+
return this.fileBackups.disableFilesBackups()
191+
}
192+
193+
public changeFilesBackupsLocation(): Promise<string | undefined> {
194+
return this.fileBackups.changeFilesBackupsLocation()
195+
}
196+
197+
public getFilesBackupsLocation(): Promise<string> {
198+
return this.fileBackups.getFilesBackupsLocation()
199+
}
200+
201+
public openFilesBackupsLocation(): Promise<void> {
202+
return this.fileBackups.openFilesBackupsLocation()
203+
}
154204
}

0 commit comments

Comments
 (0)