feat: Automatic plaintext backup option in Preferences > Backups will backup your notes and tags into plaintext, unencrypted folders on your computer. In addition, automatic encrypted text backups preference management has moved from the top-level menu in the desktop app to Preferences > Backups. (#2322)
This commit is contained in:
@@ -1,245 +0,0 @@
|
||||
import { dialog, shell, WebContents } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { AppMessageType, MessageType } from '../../../../test/TestIpcMessage'
|
||||
import { AppState } from '../../../AppState'
|
||||
import { MessageToWebApp } from '../../Shared/IpcMessages'
|
||||
import { StoreKeys } from '../Store/StoreKeys'
|
||||
import { backups as str } from '../Strings'
|
||||
import { Paths } from '../Types/Paths'
|
||||
import {
|
||||
deleteDir,
|
||||
deleteDirContents,
|
||||
ensureDirectoryExists,
|
||||
FileDoesNotExist,
|
||||
moveFiles,
|
||||
openDirectoryPicker,
|
||||
} from '../Utils/FileUtils'
|
||||
import { handleTestMessage, send } from '../Utils/Testing'
|
||||
import { isTesting, last } from '../Utils/Utils'
|
||||
import { BackupsManagerInterface } from './BackupsManagerInterface'
|
||||
|
||||
function log(...message: any) {
|
||||
console.log('BackupsManager:', ...message)
|
||||
}
|
||||
|
||||
function logError(...message: any) {
|
||||
console.error('BackupsManager:', ...message)
|
||||
}
|
||||
|
||||
export const enum EnsureRecentBackupExists {
|
||||
Success = 0,
|
||||
BackupsAreDisabled = 1,
|
||||
FailedToCreateBackup = 2,
|
||||
}
|
||||
|
||||
export const BackupsDirectoryName = 'Standard Notes Backups'
|
||||
const BackupFileExtension = '.txt'
|
||||
|
||||
function backupFileNameToDate(string: string): number {
|
||||
string = path.basename(string, '.txt')
|
||||
const dateTimeDelimiter = string.indexOf('T')
|
||||
const date = string.slice(0, dateTimeDelimiter)
|
||||
|
||||
const time = string.slice(dateTimeDelimiter + 1).replace(/-/g, ':')
|
||||
return Date.parse(date + 'T' + time)
|
||||
}
|
||||
|
||||
function dateToSafeFilename(date: Date) {
|
||||
return date.toISOString().replace(/:/g, '-')
|
||||
}
|
||||
|
||||
async function copyDecryptScript(location: string) {
|
||||
try {
|
||||
await ensureDirectoryExists(location)
|
||||
await fs.copyFile(Paths.decryptScript, path.join(location, path.basename(Paths.decryptScript)))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackupsManager(webContents: WebContents, appState: AppState): BackupsManagerInterface {
|
||||
let backupsLocation = appState.store.get(StoreKeys.BackupsLocation)
|
||||
let backupsDisabled = appState.store.get(StoreKeys.BackupsDisabled)
|
||||
let needsBackup = false
|
||||
|
||||
if (!backupsDisabled) {
|
||||
void copyDecryptScript(backupsLocation)
|
||||
}
|
||||
|
||||
determineLastBackupDate(backupsLocation)
|
||||
.then((date) => appState.setBackupCreationDate(date))
|
||||
.catch(console.error)
|
||||
|
||||
async function setBackupsLocation(location: string) {
|
||||
const previousLocation = backupsLocation
|
||||
if (previousLocation === location) {
|
||||
return
|
||||
}
|
||||
|
||||
const newLocation = path.join(location, BackupsDirectoryName)
|
||||
let previousLocationFiles = await fs.readdir(previousLocation)
|
||||
const backupFiles = previousLocationFiles
|
||||
.filter((fileName) => fileName.endsWith(BackupFileExtension))
|
||||
.map((fileName) => path.join(previousLocation, fileName))
|
||||
|
||||
await moveFiles(backupFiles, newLocation)
|
||||
await copyDecryptScript(newLocation)
|
||||
|
||||
previousLocationFiles = await fs.readdir(previousLocation)
|
||||
if (previousLocationFiles.length === 0 || previousLocationFiles[0] === path.basename(Paths.decryptScript)) {
|
||||
await deleteDir(previousLocation)
|
||||
}
|
||||
|
||||
/** Wait for the operation to be successful before saving new location */
|
||||
backupsLocation = newLocation
|
||||
appState.store.set(StoreKeys.BackupsLocation, backupsLocation)
|
||||
}
|
||||
|
||||
async function saveBackupData(data: any) {
|
||||
if (backupsDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
let success: boolean
|
||||
let name: string | undefined
|
||||
|
||||
try {
|
||||
name = await writeDataToFile(data)
|
||||
log(`Data backup successfully saved: ${name}`)
|
||||
success = true
|
||||
appState.setBackupCreationDate(Date.now())
|
||||
} catch (err) {
|
||||
success = false
|
||||
logError('An error occurred saving backup file', err)
|
||||
}
|
||||
|
||||
webContents.send(MessageToWebApp.FinishedSavingBackup, { success })
|
||||
|
||||
if (isTesting()) {
|
||||
send(AppMessageType.SavedBackup)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
function performBackup() {
|
||||
if (backupsDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send(MessageToWebApp.PerformAutomatedBackup)
|
||||
}
|
||||
|
||||
async function writeDataToFile(data: any): Promise<string> {
|
||||
await ensureDirectoryExists(backupsLocation)
|
||||
|
||||
const name = dateToSafeFilename(new Date()) + BackupFileExtension
|
||||
const filePath = path.join(backupsLocation, name)
|
||||
await fs.writeFile(filePath, data)
|
||||
return name
|
||||
}
|
||||
|
||||
let interval: NodeJS.Timeout | undefined
|
||||
function beginBackups() {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
needsBackup = true
|
||||
const hoursInterval = 12
|
||||
const seconds = hoursInterval * 60 * 60
|
||||
const milliseconds = seconds * 1000
|
||||
interval = setInterval(performBackup, milliseconds)
|
||||
}
|
||||
|
||||
function toggleBackupsStatus() {
|
||||
backupsDisabled = !backupsDisabled
|
||||
appState.store.set(StoreKeys.BackupsDisabled, backupsDisabled)
|
||||
/** Create a backup on reactivation. */
|
||||
if (!backupsDisabled) {
|
||||
performBackup()
|
||||
}
|
||||
}
|
||||
|
||||
if (isTesting()) {
|
||||
handleTestMessage(MessageType.DataArchive, (data: any) => saveBackupData(data))
|
||||
handleTestMessage(MessageType.BackupsAreEnabled, () => !backupsDisabled)
|
||||
handleTestMessage(MessageType.ToggleBackupsEnabled, toggleBackupsStatus)
|
||||
handleTestMessage(MessageType.BackupsLocation, () => backupsLocation)
|
||||
handleTestMessage(MessageType.PerformBackup, performBackup)
|
||||
handleTestMessage(MessageType.CopyDecryptScript, copyDecryptScript)
|
||||
handleTestMessage(MessageType.ChangeBackupsLocation, setBackupsLocation)
|
||||
}
|
||||
|
||||
return {
|
||||
get backupsAreEnabled() {
|
||||
return !backupsDisabled
|
||||
},
|
||||
get backupsLocation() {
|
||||
return backupsLocation
|
||||
},
|
||||
saveBackupData,
|
||||
performBackup,
|
||||
beginBackups,
|
||||
toggleBackupsStatus,
|
||||
async backupsCount(): Promise<number> {
|
||||
let files = await fs.readdir(backupsLocation)
|
||||
files = files.filter((fileName) => fileName.endsWith(BackupFileExtension))
|
||||
return files.length
|
||||
},
|
||||
applicationDidBlur() {
|
||||
if (needsBackup) {
|
||||
needsBackup = false
|
||||
performBackup()
|
||||
}
|
||||
},
|
||||
viewBackups() {
|
||||
void shell.openPath(backupsLocation)
|
||||
},
|
||||
async deleteBackups() {
|
||||
await deleteDirContents(backupsLocation)
|
||||
return copyDecryptScript(backupsLocation)
|
||||
},
|
||||
|
||||
async changeBackupsLocation() {
|
||||
const path = await openDirectoryPicker()
|
||||
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await setBackupsLocation(path)
|
||||
performBackup()
|
||||
} catch (e) {
|
||||
logError(e)
|
||||
void dialog.showMessageBox({
|
||||
message: str().errorChangingDirectory(e),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function determineLastBackupDate(backupsLocation: string): Promise<number | null> {
|
||||
try {
|
||||
const files = (await fs.readdir(backupsLocation))
|
||||
.filter((filename) => filename.endsWith(BackupFileExtension) && !Number.isNaN(backupFileNameToDate(filename)))
|
||||
.sort()
|
||||
const lastBackupFileName = last(files)
|
||||
if (!lastBackupFileName) {
|
||||
return null
|
||||
}
|
||||
const backupDate = backupFileNameToDate(lastBackupFileName)
|
||||
if (Number.isNaN(backupDate)) {
|
||||
return null
|
||||
}
|
||||
return backupDate
|
||||
} catch (error: any) {
|
||||
if (error.code !== FileDoesNotExist) {
|
||||
console.error(error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export interface BackupsManagerInterface {
|
||||
backupsAreEnabled: boolean
|
||||
toggleBackupsStatus(): void
|
||||
backupsLocation: string
|
||||
backupsCount(): Promise<number>
|
||||
applicationDidBlur(): void
|
||||
changeBackupsLocation(): void
|
||||
beginBackups(): void
|
||||
performBackup(): void
|
||||
deleteBackups(): Promise<void>
|
||||
viewBackups(): void
|
||||
saveBackupData(data: unknown): void
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import { LoggingDomain, log } from './../../../Logging'
|
||||
import {
|
||||
FileBackupRecord,
|
||||
FileBackupsDevice,
|
||||
FileBackupsMapping,
|
||||
FileBackupReadToken,
|
||||
FileBackupReadChunkResponse,
|
||||
PlaintextBackupsMapping,
|
||||
DesktopWatchedDirectoriesChange,
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { AppState } from 'app/AppState'
|
||||
import { shell } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import { WebContents, shell } from 'electron'
|
||||
import { StoreKeys } from '../Store/StoreKeys'
|
||||
import path from 'path'
|
||||
import {
|
||||
deleteFile,
|
||||
deleteFileIfExists,
|
||||
ensureDirectoryExists,
|
||||
moveDirContents,
|
||||
moveFile,
|
||||
openDirectoryPicker,
|
||||
readJSONFile,
|
||||
writeFile,
|
||||
@@ -20,6 +24,10 @@ import {
|
||||
} from '../Utils/FileUtils'
|
||||
import { FileDownloader } from './FileDownloader'
|
||||
import { FileReadOperation } from './FileReadOperation'
|
||||
import { Paths } from '../Types/Paths'
|
||||
import { MessageToWebApp } from '../../Shared/IpcMessages'
|
||||
|
||||
const TextBackupFileExtension = '.txt'
|
||||
|
||||
export const FileBackupsConstantsV1 = {
|
||||
Version: '1.0.0',
|
||||
@@ -29,107 +37,112 @@ export const FileBackupsConstantsV1 = {
|
||||
|
||||
export class FilesBackupManager implements FileBackupsDevice {
|
||||
private readOperations: Map<string, FileReadOperation> = new Map()
|
||||
private plaintextMappingCache?: PlaintextBackupsMapping
|
||||
|
||||
constructor(private appState: AppState) {}
|
||||
constructor(private appState: AppState, private webContents: WebContents) {}
|
||||
|
||||
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsEnabled))
|
||||
}
|
||||
private async findUuidForPlaintextBackupFileName(
|
||||
backupsDirectory: string,
|
||||
targetFilename: string,
|
||||
): Promise<string | undefined> {
|
||||
const mapping = await this.getPlaintextBackupsMappingFile(backupsDirectory)
|
||||
|
||||
public async enableFilesBackups(): Promise<void> {
|
||||
const currentLocation = await this.getFilesBackupsLocation()
|
||||
|
||||
if (!currentLocation) {
|
||||
const result = await this.changeFilesBackupsLocation()
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
const uuid = Object.keys(mapping.files).find((uuid) => {
|
||||
const entries = mapping.files[uuid]
|
||||
for (const entry of entries) {
|
||||
const filePath = entry.path
|
||||
const filename = path.basename(filePath)
|
||||
if (filename === targetFilename) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
this.appState.store.set(StoreKeys.FileBackupsEnabled, true)
|
||||
|
||||
const mapping = this.getMappingFileFromDisk()
|
||||
|
||||
if (!mapping) {
|
||||
await this.saveFilesBackupsMappingFile(this.defaultMappingFileValue())
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
|
||||
public disableFilesBackups(): Promise<void> {
|
||||
this.appState.store.set(StoreKeys.FileBackupsEnabled, false)
|
||||
public async migrateLegacyFileBackupsToNewStructure(newLocation: string): Promise<void> {
|
||||
const legacyLocation = await this.getLegacyFilesBackupsLocation()
|
||||
if (!legacyLocation) {
|
||||
return
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
await ensureDirectoryExists(newLocation)
|
||||
|
||||
const legacyMappingLocation = `${legacyLocation}/info.json`
|
||||
const newMappingLocation = this.getFileBackupsMappingFilePath(newLocation)
|
||||
await ensureDirectoryExists(path.dirname(newMappingLocation))
|
||||
await moveFile(legacyMappingLocation, newMappingLocation)
|
||||
|
||||
await moveDirContents(legacyLocation, newLocation)
|
||||
}
|
||||
|
||||
public async changeFilesBackupsLocation(): Promise<string | undefined> {
|
||||
const newPath = await openDirectoryPicker()
|
||||
public async isLegacyFilesBackupsEnabled(): Promise<boolean> {
|
||||
return this.appState.store.get(StoreKeys.LegacyFileBackupsEnabled)
|
||||
}
|
||||
|
||||
if (!newPath) {
|
||||
async wasLegacyTextBackupsExplicitlyDisabled(): Promise<boolean> {
|
||||
const value = this.appState.store.get(StoreKeys.LegacyTextBackupsDisabled)
|
||||
return value === true
|
||||
}
|
||||
|
||||
async getUserDocumentsDirectory(): Promise<string> {
|
||||
return Paths.documentsDir
|
||||
}
|
||||
|
||||
public async getLegacyFilesBackupsLocation(): Promise<string | undefined> {
|
||||
return this.appState.store.get(StoreKeys.LegacyFileBackupsLocation)
|
||||
}
|
||||
|
||||
async getLegacyTextBackupsLocation(): Promise<string | undefined> {
|
||||
const savedLocation = this.appState.store.get(StoreKeys.LegacyTextBackupsLocation)
|
||||
if (savedLocation) {
|
||||
return savedLocation
|
||||
}
|
||||
|
||||
const LegacyTextBackupsDirectory = 'Standard Notes Backups'
|
||||
return `${Paths.homeDir}/${LegacyTextBackupsDirectory}`
|
||||
}
|
||||
|
||||
public async presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
appendPath: string,
|
||||
oldLocation?: string,
|
||||
): Promise<string | undefined> {
|
||||
const selectedDirectory = await openDirectoryPicker('Select')
|
||||
|
||||
if (!selectedDirectory) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const oldPath = await this.getFilesBackupsLocation()
|
||||
const newPath = path.join(selectedDirectory, path.normalize(appendPath))
|
||||
|
||||
if (oldPath) {
|
||||
await this.transferFilesBackupsToNewLocation(oldPath, newPath)
|
||||
} else {
|
||||
this.appState.store.set(StoreKeys.FileBackupsLocation, newPath)
|
||||
await ensureDirectoryExists(newPath)
|
||||
|
||||
if (oldLocation) {
|
||||
await moveDirContents(path.normalize(oldLocation), newPath)
|
||||
}
|
||||
|
||||
return newPath
|
||||
}
|
||||
|
||||
private async transferFilesBackupsToNewLocation(oldPath: string, newPath: string): Promise<void> {
|
||||
const mapping = await this.getMappingFileFromDisk()
|
||||
if (!mapping) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = Object.values(mapping.files)
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(oldPath, entry.relativePath)
|
||||
const destinationPath = path.join(newPath, entry.relativePath)
|
||||
await moveDirContents(sourcePath, destinationPath)
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
entry.absolutePath = path.join(newPath, entry.relativePath)
|
||||
}
|
||||
|
||||
const oldMappingFileLocation = this.getMappingFileLocation()
|
||||
|
||||
this.appState.store.set(StoreKeys.FileBackupsLocation, newPath)
|
||||
|
||||
const result = await this.saveFilesBackupsMappingFile(mapping)
|
||||
|
||||
if (result === 'success') {
|
||||
await deleteFile(oldMappingFileLocation)
|
||||
}
|
||||
private getFileBackupsMappingFilePath(backupsLocation: string): string {
|
||||
return `${backupsLocation}/.settings/info.json`
|
||||
}
|
||||
|
||||
public getFilesBackupsLocation(): Promise<string> {
|
||||
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsLocation))
|
||||
private async getFileBackupsMappingFileFromDisk(backupsLocation: string): Promise<FileBackupsMapping | undefined> {
|
||||
return readJSONFile<FileBackupsMapping>(this.getFileBackupsMappingFilePath(backupsLocation))
|
||||
}
|
||||
|
||||
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 {
|
||||
private defaulFileBackupstMappingFileValue(): FileBackupsMapping {
|
||||
return { version: FileBackupsConstantsV1.Version, files: {} }
|
||||
}
|
||||
|
||||
async getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
|
||||
const data = await this.getMappingFileFromDisk()
|
||||
async getFilesBackupsMappingFile(backupsLocation: string): Promise<FileBackupsMapping> {
|
||||
const data = await this.getFileBackupsMappingFileFromDisk(backupsLocation)
|
||||
|
||||
if (!data) {
|
||||
return this.defaultMappingFileValue()
|
||||
return this.defaulFileBackupstMappingFileValue()
|
||||
}
|
||||
|
||||
for (const entry of Object.values(data.files)) {
|
||||
@@ -139,23 +152,18 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
return data
|
||||
}
|
||||
|
||||
async openFilesBackupsLocation(): Promise<void> {
|
||||
const location = await this.getFilesBackupsLocation()
|
||||
|
||||
async openLocation(location: string): Promise<void> {
|
||||
void shell.openPath(location)
|
||||
}
|
||||
|
||||
async openFileBackup(record: FileBackupRecord): Promise<void> {
|
||||
void shell.openPath(record.absolutePath)
|
||||
}
|
||||
|
||||
async saveFilesBackupsMappingFile(file: FileBackupsMapping): Promise<'success' | 'failed'> {
|
||||
await writeJSONFile(this.getMappingFileLocation(), file)
|
||||
private async saveFilesBackupsMappingFile(location: string, file: FileBackupsMapping): Promise<'success' | 'failed'> {
|
||||
await writeJSONFile(this.getFileBackupsMappingFilePath(location), file)
|
||||
|
||||
return 'success'
|
||||
}
|
||||
|
||||
async saveFilesBackupsFile(
|
||||
location: string,
|
||||
uuid: string,
|
||||
metaFile: string,
|
||||
downloadRequest: {
|
||||
@@ -164,9 +172,7 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
url: string
|
||||
},
|
||||
): Promise<'success' | 'failed'> {
|
||||
const backupsDir = await this.getFilesBackupsLocation()
|
||||
|
||||
const fileDir = `${backupsDir}/${uuid}`
|
||||
const fileDir = `${location}/${uuid}`
|
||||
const metaFilePath = `${fileDir}/${FileBackupsConstantsV1.MetadataFileName}`
|
||||
const binaryPath = `${fileDir}/${FileBackupsConstantsV1.BinaryFileName}`
|
||||
|
||||
@@ -184,25 +190,24 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
const result = await downloader.run()
|
||||
|
||||
if (result === 'success') {
|
||||
const mapping = await this.getFilesBackupsMappingFile()
|
||||
const mapping = await this.getFilesBackupsMappingFile(location)
|
||||
|
||||
mapping.files[uuid] = {
|
||||
backedUpOn: new Date(),
|
||||
absolutePath: fileDir,
|
||||
relativePath: uuid,
|
||||
metadataFileName: FileBackupsConstantsV1.MetadataFileName,
|
||||
binaryFileName: FileBackupsConstantsV1.BinaryFileName,
|
||||
version: FileBackupsConstantsV1.Version,
|
||||
}
|
||||
|
||||
await this.saveFilesBackupsMappingFile(mapping)
|
||||
await this.saveFilesBackupsMappingFile(location, mapping)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getFileBackupReadToken(record: FileBackupRecord): Promise<FileBackupReadToken> {
|
||||
const operation = new FileReadOperation(record)
|
||||
async getFileBackupReadToken(filePath: string): Promise<FileBackupReadToken> {
|
||||
const operation = new FileReadOperation(filePath)
|
||||
|
||||
this.readOperations.set(operation.token, operation)
|
||||
|
||||
@@ -224,4 +229,180 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getTextBackupsCount(location: string): Promise<number> {
|
||||
let files = await fs.readdir(location)
|
||||
files = files.filter((fileName) => fileName.endsWith(TextBackupFileExtension))
|
||||
return files.length
|
||||
}
|
||||
|
||||
async saveTextBackupData(location: string, data: string): Promise<void> {
|
||||
log(LoggingDomain.Backups, 'Saving text backup data', 'to', location)
|
||||
let success: boolean
|
||||
|
||||
try {
|
||||
await ensureDirectoryExists(location)
|
||||
const name = `${new Date().toISOString().replace(/:/g, '-')}${TextBackupFileExtension}`
|
||||
const filePath = path.join(location, name)
|
||||
await fs.writeFile(filePath, data)
|
||||
success = true
|
||||
} catch (err) {
|
||||
success = false
|
||||
console.error('An error occurred saving backup file', err)
|
||||
}
|
||||
|
||||
log(LoggingDomain.Backups, 'Finished saving text backup data', { success })
|
||||
}
|
||||
|
||||
async copyDecryptScript(location: string) {
|
||||
try {
|
||||
await ensureDirectoryExists(location)
|
||||
await fs.copyFile(Paths.decryptScript, path.join(location, path.basename(Paths.decryptScript)))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
private getPlaintextMappingFilePath(location: string): string {
|
||||
return `${location}/.settings/info.json`
|
||||
}
|
||||
|
||||
private async getPlaintextMappingFileFromDisk(location: string): Promise<PlaintextBackupsMapping | undefined> {
|
||||
return readJSONFile<PlaintextBackupsMapping>(this.getPlaintextMappingFilePath(location))
|
||||
}
|
||||
|
||||
private async savePlaintextBackupsMappingFile(
|
||||
location: string,
|
||||
file: PlaintextBackupsMapping,
|
||||
): Promise<'success' | 'failed'> {
|
||||
await writeJSONFile(this.getPlaintextMappingFilePath(location), file)
|
||||
|
||||
return 'success'
|
||||
}
|
||||
|
||||
private defaultPlaintextMappingFileValue(): PlaintextBackupsMapping {
|
||||
return { version: '1.0', files: {} }
|
||||
}
|
||||
|
||||
async getPlaintextBackupsMappingFile(location: string): Promise<PlaintextBackupsMapping> {
|
||||
if (this.plaintextMappingCache) {
|
||||
return this.plaintextMappingCache
|
||||
}
|
||||
|
||||
let data = await this.getPlaintextMappingFileFromDisk(location)
|
||||
|
||||
if (!data) {
|
||||
data = this.defaultPlaintextMappingFileValue()
|
||||
}
|
||||
|
||||
this.plaintextMappingCache = data
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async savePlaintextNoteBackup(
|
||||
location: string,
|
||||
uuid: string,
|
||||
name: string,
|
||||
tags: string[],
|
||||
data: string,
|
||||
): Promise<void> {
|
||||
log(LoggingDomain.Backups, 'Saving plaintext note backup', uuid, 'to', location)
|
||||
|
||||
const mapping = await this.getPlaintextBackupsMappingFile(location)
|
||||
if (!mapping.files[uuid]) {
|
||||
mapping.files[uuid] = []
|
||||
}
|
||||
|
||||
const removeNoteFromAllDirectories = async () => {
|
||||
const records = mapping.files[uuid]
|
||||
for (const record of records) {
|
||||
const filePath = path.join(location, record.path)
|
||||
await deleteFileIfExists(filePath)
|
||||
}
|
||||
mapping.files[uuid] = []
|
||||
}
|
||||
|
||||
await removeNoteFromAllDirectories()
|
||||
|
||||
const writeFileToPath = async (absolutePath: string, filename: string, data: string, forTag?: string) => {
|
||||
const findMappingRecord = (tag?: string) => {
|
||||
const records = mapping.files[uuid]
|
||||
return records.find((record) => record.tag === tag)
|
||||
}
|
||||
|
||||
await ensureDirectoryExists(absolutePath)
|
||||
|
||||
const relativePath = forTag ?? ''
|
||||
const filenameWithSlashesEscaped = filename.replace(/\//g, '\u2215')
|
||||
const fileAbsolutePath = path.join(absolutePath, relativePath, filenameWithSlashesEscaped)
|
||||
await writeFile(fileAbsolutePath, data)
|
||||
|
||||
const existingRecord = findMappingRecord(forTag)
|
||||
if (!existingRecord) {
|
||||
mapping.files[uuid].push({
|
||||
tag: forTag,
|
||||
path: path.join(relativePath, filename),
|
||||
})
|
||||
} else {
|
||||
existingRecord.path = path.join(relativePath, filename)
|
||||
existingRecord.tag = forTag
|
||||
}
|
||||
}
|
||||
|
||||
const uuidPart = uuid.split('-')[0]
|
||||
const condensedUuidPart = uuidPart.substring(0, 4)
|
||||
if (tags.length === 0) {
|
||||
await writeFileToPath(location, `${name}-${condensedUuidPart}.txt`, data)
|
||||
} else {
|
||||
for (const tag of tags) {
|
||||
await writeFileToPath(location, `${name}-${condensedUuidPart}.txt`, data, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async persistPlaintextBackupsMappingFile(location: string): Promise<void> {
|
||||
if (!this.plaintextMappingCache) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.savePlaintextBackupsMappingFile(location, this.plaintextMappingCache)
|
||||
}
|
||||
|
||||
async monitorPlaintextBackupsLocationForChanges(backupsDirectory: string): Promise<void> {
|
||||
const FEATURE_ENABLED = false
|
||||
if (!FEATURE_ENABLED) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const watcher = fs.watch(backupsDirectory, { recursive: true })
|
||||
for await (const event of watcher) {
|
||||
const { eventType, filename } = event
|
||||
if (eventType !== 'change' && eventType !== 'rename') {
|
||||
continue
|
||||
}
|
||||
const itemUuid = await this.findUuidForPlaintextBackupFileName(backupsDirectory, filename)
|
||||
if (itemUuid) {
|
||||
try {
|
||||
const change: DesktopWatchedDirectoriesChange = {
|
||||
itemUuid,
|
||||
path: path.join(backupsDirectory, filename),
|
||||
type: eventType,
|
||||
content: await fs.readFile(path.join(backupsDirectory, filename), 'utf-8'),
|
||||
}
|
||||
this.webContents.send(MessageToWebApp.WatchedDirectoriesChanges, [change])
|
||||
} catch (err) {
|
||||
log(LoggingDomain.Backups, 'Error processing watched change', err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FileBackupReadChunkResponse, FileBackupRecord } from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { FileBackupReadChunkResponse } from '@web/Application/Device/DesktopSnjsExports'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const ONE_MB = 1024 * 1024
|
||||
const CHUNK_LIMIT = ONE_MB * 5
|
||||
@@ -11,9 +10,9 @@ export class FileReadOperation {
|
||||
private localFileId: number
|
||||
private fileLength: number
|
||||
|
||||
constructor(backupRecord: FileBackupRecord) {
|
||||
this.token = backupRecord.absolutePath
|
||||
this.localFileId = fs.openSync(path.join(backupRecord.absolutePath, backupRecord.binaryFileName), 'r')
|
||||
constructor(filePath: string) {
|
||||
this.token = filePath
|
||||
this.localFileId = fs.openSync(filePath, 'r')
|
||||
this.fileLength = fs.fstatSync(this.localFileId).size
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { checkForUpdate, openChangelog, showUpdateInstallationDialog } from '../
|
||||
import { handleTestMessage } from '../Utils/Testing'
|
||||
import { isDev, isTesting } from '../Utils/Utils'
|
||||
import { MessageType } from './../../../../test/TestIpcMessage'
|
||||
import { BackupsManagerInterface } from './../Backups/BackupsManagerInterface'
|
||||
import { SpellcheckerManager } from './../SpellcheckerManager'
|
||||
import { MenuManagerInterface } from './MenuManagerInterface'
|
||||
|
||||
@@ -112,14 +111,12 @@ function suggestionsMenu(
|
||||
export function createMenuManager({
|
||||
window,
|
||||
appState,
|
||||
backupsManager,
|
||||
trayManager,
|
||||
store,
|
||||
spellcheckerManager,
|
||||
}: {
|
||||
window: Electron.BrowserWindow
|
||||
appState: AppState
|
||||
backupsManager: BackupsManagerInterface
|
||||
trayManager: TrayManager
|
||||
store: Store
|
||||
spellcheckerManager?: SpellcheckerManager
|
||||
@@ -167,7 +164,6 @@ export function createMenuManager({
|
||||
editMenu(spellcheckerManager, reload),
|
||||
viewMenu(window, store, reload),
|
||||
windowMenu(store, trayManager, reload),
|
||||
backupsMenu(backupsManager, reload),
|
||||
updateMenu(window, appState),
|
||||
...(isLinux() ? [keyringMenu(window, store)] : []),
|
||||
helpMenu(window, shell),
|
||||
@@ -468,34 +464,6 @@ function minimizeToTrayItem(store: Store, trayManager: TrayManager, reload: () =
|
||||
}
|
||||
}
|
||||
|
||||
function backupsMenu(archiveManager: BackupsManagerInterface, reload: () => any) {
|
||||
return {
|
||||
label: str().backups,
|
||||
submenu: [
|
||||
{
|
||||
label: archiveManager.backupsAreEnabled ? str().disableAutomaticBackups : str().enableAutomaticBackups,
|
||||
click() {
|
||||
archiveManager.toggleBackupsStatus()
|
||||
reload()
|
||||
},
|
||||
},
|
||||
Separator,
|
||||
{
|
||||
label: str().changeBackupsLocation,
|
||||
click() {
|
||||
archiveManager.changeBackupsLocation()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: str().openBackupsLocation,
|
||||
click() {
|
||||
void shell.openPath(archiveManager.backupsLocation)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function updateMenu(window: BrowserWindow, appState: AppState) {
|
||||
const updateState = appState.updates
|
||||
let label
|
||||
|
||||
@@ -8,12 +8,11 @@ const rendererPath = path.join('file://', __dirname, '/renderer.js')
|
||||
import {
|
||||
FileBackupsDevice,
|
||||
FileBackupsMapping,
|
||||
FileBackupRecord,
|
||||
FileBackupReadToken,
|
||||
FileBackupReadChunkResponse,
|
||||
PlaintextBackupsMapping,
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { BackupsManagerInterface } from '../Backups/BackupsManagerInterface'
|
||||
import { KeychainInterface } from '../Keychain/KeychainInterface'
|
||||
import { MenuManagerInterface } from '../Menus/MenuManagerInterface'
|
||||
import { Component, PackageManagerInterface } from '../Packages/PackageManagerInterface'
|
||||
@@ -29,7 +28,6 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
constructor(
|
||||
private window: BrowserWindow,
|
||||
private keychain: KeychainInterface,
|
||||
private backups: BackupsManagerInterface,
|
||||
private packages: PackageManagerInterface,
|
||||
private search: SearchManagerInterface,
|
||||
private data: RemoteDataInterface,
|
||||
@@ -54,28 +52,30 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
getKeychainValue: this.getKeychainValue.bind(this),
|
||||
setKeychainValue: this.setKeychainValue.bind(this),
|
||||
clearKeychainValue: this.clearKeychainValue.bind(this),
|
||||
localBackupsCount: this.localBackupsCount.bind(this),
|
||||
viewlocalBackups: this.viewlocalBackups.bind(this),
|
||||
deleteLocalBackups: this.deleteLocalBackups.bind(this),
|
||||
displayAppMenu: this.displayAppMenu.bind(this),
|
||||
saveDataBackup: this.saveDataBackup.bind(this),
|
||||
syncComponents: this.syncComponents.bind(this),
|
||||
onMajorDataChange: this.onMajorDataChange.bind(this),
|
||||
onSearch: this.onSearch.bind(this),
|
||||
onInitialDataLoad: this.onInitialDataLoad.bind(this),
|
||||
destroyAllData: this.destroyAllData.bind(this),
|
||||
getFilesBackupsMappingFile: this.getFilesBackupsMappingFile.bind(this),
|
||||
saveFilesBackupsFile: this.saveFilesBackupsFile.bind(this),
|
||||
isFilesBackupsEnabled: this.isFilesBackupsEnabled.bind(this),
|
||||
enableFilesBackups: this.enableFilesBackups.bind(this),
|
||||
disableFilesBackups: this.disableFilesBackups.bind(this),
|
||||
changeFilesBackupsLocation: this.changeFilesBackupsLocation.bind(this),
|
||||
getFilesBackupsLocation: this.getFilesBackupsLocation.bind(this),
|
||||
openFilesBackupsLocation: this.openFilesBackupsLocation.bind(this),
|
||||
openFileBackup: this.openFileBackup.bind(this),
|
||||
isLegacyFilesBackupsEnabled: this.isLegacyFilesBackupsEnabled.bind(this),
|
||||
getLegacyFilesBackupsLocation: this.getLegacyFilesBackupsLocation.bind(this),
|
||||
getFileBackupReadToken: this.getFileBackupReadToken.bind(this),
|
||||
readNextChunk: this.readNextChunk.bind(this),
|
||||
askForMediaAccess: this.askForMediaAccess.bind(this),
|
||||
wasLegacyTextBackupsExplicitlyDisabled: this.wasLegacyTextBackupsExplicitlyDisabled.bind(this),
|
||||
getLegacyTextBackupsLocation: this.getLegacyTextBackupsLocation.bind(this),
|
||||
saveTextBackupData: this.saveTextBackupData.bind(this),
|
||||
savePlaintextNoteBackup: this.savePlaintextNoteBackup.bind(this),
|
||||
openLocation: this.openLocation.bind(this),
|
||||
presentDirectoryPickerForLocationChangeAndTransferOld:
|
||||
this.presentDirectoryPickerForLocationChangeAndTransferOld.bind(this),
|
||||
getPlaintextBackupsMappingFile: this.getPlaintextBackupsMappingFile.bind(this),
|
||||
persistPlaintextBackupsMappingFile: this.persistPlaintextBackupsMappingFile.bind(this),
|
||||
getTextBackupsCount: this.getTextBackupsCount.bind(this),
|
||||
migrateLegacyFileBackupsToNewStructure: this.migrateLegacyFileBackupsToNewStructure.bind(this),
|
||||
getUserDocumentsDirectory: this.getUserDocumentsDirectory.bind(this),
|
||||
monitorPlaintextBackupsLocationForChanges: this.monitorPlaintextBackupsLocationForChanges.bind(this),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,51 +135,28 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
return this.keychain.clearKeychainValue()
|
||||
}
|
||||
|
||||
async localBackupsCount() {
|
||||
return this.backups.backupsCount()
|
||||
}
|
||||
|
||||
viewlocalBackups() {
|
||||
this.backups.viewBackups()
|
||||
}
|
||||
|
||||
async deleteLocalBackups() {
|
||||
return this.backups.deleteBackups()
|
||||
}
|
||||
|
||||
syncComponents(components: Component[]) {
|
||||
void this.packages.syncComponents(components)
|
||||
}
|
||||
|
||||
onMajorDataChange() {
|
||||
this.backups.performBackup()
|
||||
}
|
||||
|
||||
onSearch(text: string) {
|
||||
this.search.findInPage(text)
|
||||
}
|
||||
|
||||
onInitialDataLoad() {
|
||||
this.backups.beginBackups()
|
||||
}
|
||||
|
||||
destroyAllData() {
|
||||
this.data.destroySensitiveDirectories()
|
||||
}
|
||||
|
||||
saveDataBackup(data: unknown) {
|
||||
this.backups.saveBackupData(data)
|
||||
}
|
||||
|
||||
displayAppMenu() {
|
||||
this.menus.popupMenu()
|
||||
}
|
||||
|
||||
getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
|
||||
return this.fileBackups.getFilesBackupsMappingFile()
|
||||
getFilesBackupsMappingFile(location: string): Promise<FileBackupsMapping> {
|
||||
return this.fileBackups.getFilesBackupsMappingFile(location)
|
||||
}
|
||||
|
||||
saveFilesBackupsFile(
|
||||
location: string,
|
||||
uuid: string,
|
||||
metaFile: string,
|
||||
downloadRequest: {
|
||||
@@ -188,43 +165,74 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
url: string
|
||||
},
|
||||
): Promise<'success' | 'failed'> {
|
||||
return this.fileBackups.saveFilesBackupsFile(uuid, metaFile, downloadRequest)
|
||||
return this.fileBackups.saveFilesBackupsFile(location, uuid, metaFile, downloadRequest)
|
||||
}
|
||||
|
||||
getFileBackupReadToken(record: FileBackupRecord): Promise<FileBackupReadToken> {
|
||||
return this.fileBackups.getFileBackupReadToken(record)
|
||||
getFileBackupReadToken(filePath: string): Promise<FileBackupReadToken> {
|
||||
return this.fileBackups.getFileBackupReadToken(filePath)
|
||||
}
|
||||
|
||||
readNextChunk(nextToken: string): Promise<FileBackupReadChunkResponse> {
|
||||
return this.fileBackups.readNextChunk(nextToken)
|
||||
}
|
||||
|
||||
public isFilesBackupsEnabled(): Promise<boolean> {
|
||||
return this.fileBackups.isFilesBackupsEnabled()
|
||||
public isLegacyFilesBackupsEnabled(): Promise<boolean> {
|
||||
return this.fileBackups.isLegacyFilesBackupsEnabled()
|
||||
}
|
||||
|
||||
public enableFilesBackups(): Promise<void> {
|
||||
return this.fileBackups.enableFilesBackups()
|
||||
public getLegacyFilesBackupsLocation(): Promise<string | undefined> {
|
||||
return this.fileBackups.getLegacyFilesBackupsLocation()
|
||||
}
|
||||
|
||||
public disableFilesBackups(): Promise<void> {
|
||||
return this.fileBackups.disableFilesBackups()
|
||||
wasLegacyTextBackupsExplicitlyDisabled(): Promise<boolean> {
|
||||
return this.fileBackups.wasLegacyTextBackupsExplicitlyDisabled()
|
||||
}
|
||||
|
||||
public changeFilesBackupsLocation(): Promise<string | undefined> {
|
||||
return this.fileBackups.changeFilesBackupsLocation()
|
||||
getLegacyTextBackupsLocation(): Promise<string | undefined> {
|
||||
return this.fileBackups.getLegacyTextBackupsLocation()
|
||||
}
|
||||
|
||||
public getFilesBackupsLocation(): Promise<string> {
|
||||
return this.fileBackups.getFilesBackupsLocation()
|
||||
saveTextBackupData(location: string, data: string): Promise<void> {
|
||||
return this.fileBackups.saveTextBackupData(location, data)
|
||||
}
|
||||
|
||||
public openFilesBackupsLocation(): Promise<void> {
|
||||
return this.fileBackups.openFilesBackupsLocation()
|
||||
savePlaintextNoteBackup(location: string, uuid: string, name: string, tags: string[], data: string): Promise<void> {
|
||||
return this.fileBackups.savePlaintextNoteBackup(location, uuid, name, tags, data)
|
||||
}
|
||||
|
||||
public openFileBackup(record: FileBackupRecord): Promise<void> {
|
||||
return this.fileBackups.openFileBackup(record)
|
||||
openLocation(path: string): Promise<void> {
|
||||
return this.fileBackups.openLocation(path)
|
||||
}
|
||||
|
||||
presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
appendPath: string,
|
||||
oldLocation?: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
return this.fileBackups.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation)
|
||||
}
|
||||
|
||||
getPlaintextBackupsMappingFile(location: string): Promise<PlaintextBackupsMapping> {
|
||||
return this.fileBackups.getPlaintextBackupsMappingFile(location)
|
||||
}
|
||||
|
||||
persistPlaintextBackupsMappingFile(location: string): Promise<void> {
|
||||
return this.fileBackups.persistPlaintextBackupsMappingFile(location)
|
||||
}
|
||||
|
||||
getTextBackupsCount(location: string): Promise<number> {
|
||||
return this.fileBackups.getTextBackupsCount(location)
|
||||
}
|
||||
|
||||
migrateLegacyFileBackupsToNewStructure(newPath: string): Promise<void> {
|
||||
return this.fileBackups.migrateLegacyFileBackupsToNewStructure(newPath)
|
||||
}
|
||||
|
||||
getUserDocumentsDirectory(): Promise<string> {
|
||||
return this.fileBackups.getUserDocumentsDirectory()
|
||||
}
|
||||
|
||||
monitorPlaintextBackupsLocationForChanges(backupsDirectory: string): Promise<void> {
|
||||
return this.fileBackups.monitorPlaintextBackupsLocationForChanges(backupsDirectory)
|
||||
}
|
||||
|
||||
askForMediaAccess(type: 'camera' | 'microphone'): Promise<boolean> {
|
||||
|
||||
@@ -4,30 +4,34 @@ export enum StoreKeys {
|
||||
ExtServerHost = 'extServerHost',
|
||||
UseSystemMenuBar = 'useSystemMenuBar',
|
||||
MenuBarVisible = 'isMenuBarVisible',
|
||||
BackupsLocation = 'backupsLocation',
|
||||
BackupsDisabled = 'backupsDisabled',
|
||||
MinimizeToTray = 'minimizeToTray',
|
||||
EnableAutoUpdate = 'enableAutoUpdates',
|
||||
ZoomFactor = 'zoomFactor',
|
||||
SelectedSpellCheckerLanguageCodes = 'selectedSpellCheckerLanguageCodes',
|
||||
UseNativeKeychain = 'useNativeKeychain',
|
||||
FileBackupsEnabled = 'fileBackupsEnabled',
|
||||
FileBackupsLocation = 'fileBackupsLocation',
|
||||
LastRunVersion = 'LastRunVersion',
|
||||
|
||||
LegacyTextBackupsLocation = 'backupsLocation',
|
||||
LegacyTextBackupsDisabled = 'backupsDisabled',
|
||||
|
||||
LegacyFileBackupsEnabled = 'fileBackupsEnabled',
|
||||
LegacyFileBackupsLocation = 'fileBackupsLocation',
|
||||
}
|
||||
|
||||
export interface StoreData {
|
||||
[StoreKeys.ExtServerHost]: string
|
||||
[StoreKeys.UseSystemMenuBar]: boolean
|
||||
[StoreKeys.MenuBarVisible]: boolean
|
||||
[StoreKeys.BackupsLocation]: string
|
||||
[StoreKeys.BackupsDisabled]: boolean
|
||||
[StoreKeys.MinimizeToTray]: boolean
|
||||
[StoreKeys.EnableAutoUpdate]: boolean
|
||||
[StoreKeys.UseNativeKeychain]: boolean | null
|
||||
[StoreKeys.ZoomFactor]: number
|
||||
[StoreKeys.SelectedSpellCheckerLanguageCodes]: Set<Language> | null
|
||||
[StoreKeys.FileBackupsEnabled]: boolean
|
||||
[StoreKeys.FileBackupsLocation]: string
|
||||
[StoreKeys.LastRunVersion]: string
|
||||
|
||||
[StoreKeys.LegacyTextBackupsLocation]: string
|
||||
[StoreKeys.LegacyTextBackupsDisabled]: boolean
|
||||
|
||||
[StoreKeys.LegacyFileBackupsEnabled]: boolean
|
||||
[StoreKeys.LegacyFileBackupsLocation]: string
|
||||
}
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { BackupsDirectoryName } from '../Backups/BackupsManager'
|
||||
import { Language } from '../SpellcheckerManager'
|
||||
import { FileDoesNotExist } from '../Utils/FileUtils'
|
||||
import { ensureIsBoolean, isBoolean, isDev, isTesting } from '../Utils/Utils'
|
||||
import { ensureIsBoolean, isBoolean } from '../Utils/Utils'
|
||||
import { StoreData, StoreKeys } from './StoreKeys'
|
||||
import { app, logError } from './Store'
|
||||
import { logError } from './Store'
|
||||
|
||||
export function createSanitizedStoreData(data: any = {}): StoreData {
|
||||
return {
|
||||
[StoreKeys.MenuBarVisible]: ensureIsBoolean(data[StoreKeys.MenuBarVisible], true),
|
||||
[StoreKeys.UseSystemMenuBar]: ensureIsBoolean(data[StoreKeys.UseSystemMenuBar], false),
|
||||
[StoreKeys.BackupsDisabled]: ensureIsBoolean(data[StoreKeys.BackupsDisabled], false),
|
||||
[StoreKeys.MinimizeToTray]: ensureIsBoolean(data[StoreKeys.MinimizeToTray], false),
|
||||
[StoreKeys.EnableAutoUpdate]: ensureIsBoolean(data[StoreKeys.EnableAutoUpdate], true),
|
||||
[StoreKeys.UseNativeKeychain]: isBoolean(data[StoreKeys.UseNativeKeychain])
|
||||
? data[StoreKeys.UseNativeKeychain]
|
||||
: null,
|
||||
[StoreKeys.ExtServerHost]: data[StoreKeys.ExtServerHost],
|
||||
[StoreKeys.BackupsLocation]: sanitizeBackupsLocation(data[StoreKeys.BackupsLocation]),
|
||||
[StoreKeys.ZoomFactor]: sanitizeZoomFactor(data[StoreKeys.ZoomFactor]),
|
||||
[StoreKeys.SelectedSpellCheckerLanguageCodes]: sanitizeSpellCheckerLanguageCodes(
|
||||
data[StoreKeys.SelectedSpellCheckerLanguageCodes],
|
||||
),
|
||||
[StoreKeys.FileBackupsEnabled]: ensureIsBoolean(data[StoreKeys.FileBackupsEnabled], false),
|
||||
[StoreKeys.FileBackupsLocation]: data[StoreKeys.FileBackupsLocation],
|
||||
[StoreKeys.LastRunVersion]: data[StoreKeys.LastRunVersion],
|
||||
|
||||
[StoreKeys.LegacyTextBackupsLocation]: data[StoreKeys.LegacyTextBackupsLocation],
|
||||
[StoreKeys.LegacyTextBackupsDisabled]: data[StoreKeys.LegacyTextBackupsDisabled],
|
||||
[StoreKeys.LegacyFileBackupsEnabled]: data[StoreKeys.LegacyFileBackupsEnabled],
|
||||
[StoreKeys.LegacyFileBackupsLocation]: data[StoreKeys.LegacyFileBackupsLocation],
|
||||
}
|
||||
}
|
||||
function sanitizeZoomFactor(factor?: any): number {
|
||||
@@ -35,29 +34,7 @@ function sanitizeZoomFactor(factor?: any): number {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
function sanitizeBackupsLocation(location?: unknown): string {
|
||||
const defaultPath = path.join(
|
||||
isTesting() ? app.getPath('userData') : isDev() ? app.getPath('documents') : app.getPath('home'),
|
||||
BackupsDirectoryName,
|
||||
)
|
||||
|
||||
if (typeof location !== 'string') {
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(location)
|
||||
if (stat.isDirectory()) {
|
||||
return location
|
||||
}
|
||||
/** Path points to something other than a directory */
|
||||
return defaultPath
|
||||
} catch (e) {
|
||||
/** Path does not point to a valid directory */
|
||||
logError(e)
|
||||
return defaultPath
|
||||
}
|
||||
}
|
||||
function sanitizeSpellCheckerLanguageCodes(languages?: unknown): Set<Language> | null {
|
||||
if (!languages) {
|
||||
return null
|
||||
|
||||
@@ -13,8 +13,6 @@ export function createEnglishStrings(): Strings {
|
||||
automaticUpdatesDisabled: 'Automatic Updates Disabled',
|
||||
disableAutomaticBackups: 'Disable Automatic Backups',
|
||||
enableAutomaticBackups: 'Enable Automatic Backups',
|
||||
changeBackupsLocation: 'Change Backups Location',
|
||||
openBackupsLocation: 'Open Backups Location',
|
||||
emailSupport: 'Email Support',
|
||||
website: 'Website',
|
||||
gitHub: 'GitHub',
|
||||
@@ -146,15 +144,5 @@ export function createEnglishStrings(): Strings {
|
||||
},
|
||||
unknownVersionName: 'Unknown',
|
||||
},
|
||||
backups: {
|
||||
errorChangingDirectory(error: any): string {
|
||||
return (
|
||||
'An error occurred while changing your backups directory. ' +
|
||||
'If this issue persists, please contact support with the following ' +
|
||||
'information: \n' +
|
||||
JSON.stringify(error)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,15 +28,5 @@ export function createFrenchStrings(): Strings {
|
||||
},
|
||||
extensions: fallback.extensions,
|
||||
updates: fallback.updates,
|
||||
backups: {
|
||||
errorChangingDirectory(error: any): string {
|
||||
return (
|
||||
"Une erreur s'est produite lors du déplacement du dossier de " +
|
||||
'sauvegardes. Si le problème est récurrent, contactez le support ' +
|
||||
'technique (en anglais) avec les informations suivantes:\n' +
|
||||
JSON.stringify(error)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@ export function updates() {
|
||||
return str().updates
|
||||
}
|
||||
|
||||
export function backups() {
|
||||
return str().backups
|
||||
}
|
||||
|
||||
function stringsForLocale(locale: string): Strings {
|
||||
if (locale === 'en' || locale.startsWith('en-')) {
|
||||
return createEnglishStrings()
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface Strings {
|
||||
tray: TrayStrings
|
||||
extensions: ExtensionsStrings
|
||||
updates: UpdateStrings
|
||||
backups: BackupsStrings
|
||||
}
|
||||
|
||||
interface AppMenuStrings {
|
||||
@@ -18,8 +17,6 @@ interface AppMenuStrings {
|
||||
automaticUpdatesDisabled: string
|
||||
disableAutomaticBackups: string
|
||||
enableAutomaticBackups: string
|
||||
changeBackupsLocation: string
|
||||
openBackupsLocation: string
|
||||
emailSupport: string
|
||||
website: string
|
||||
gitHub: string
|
||||
@@ -103,7 +100,3 @@ interface UpdateStrings {
|
||||
}
|
||||
unknownVersionName: string
|
||||
}
|
||||
|
||||
interface BackupsStrings {
|
||||
errorChangingDirectory(error: any): string
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ export const Paths = {
|
||||
get userDataDir(): string {
|
||||
return app.getPath('userData')
|
||||
},
|
||||
get homeDir(): string {
|
||||
return app.getPath('home')
|
||||
},
|
||||
get documentsDir(): string {
|
||||
return app.getPath('documents')
|
||||
},
|
||||
|
||||
@@ -2,11 +2,10 @@ import { compareVersions } from 'compare-versions'
|
||||
import { BrowserWindow, dialog, shell } from 'electron'
|
||||
import electronLog from 'electron-log'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { action, autorun, computed, makeObservable, observable } from 'mobx'
|
||||
import { action, computed, makeObservable, observable } from 'mobx'
|
||||
import { MessageType } from '../../../test/TestIpcMessage'
|
||||
import { AppState } from '../../AppState'
|
||||
import { MessageToWebApp } from '../Shared/IpcMessages'
|
||||
import { BackupsManagerInterface } from './Backups/BackupsManagerInterface'
|
||||
import { StoreKeys } from './Store/StoreKeys'
|
||||
import { updates as str } from './Strings'
|
||||
import { autoUpdatingAvailable } from './Types/Constants'
|
||||
@@ -84,7 +83,7 @@ export class UpdateState {
|
||||
|
||||
let updatesSetup = false
|
||||
|
||||
export function setupUpdates(window: BrowserWindow, appState: AppState, backupsManager: BackupsManagerInterface): void {
|
||||
export function setupUpdates(window: BrowserWindow, appState: AppState): void {
|
||||
if (!autoUpdatingAvailable) {
|
||||
return
|
||||
}
|
||||
@@ -97,22 +96,6 @@ export function setupUpdates(window: BrowserWindow, appState: AppState, backupsM
|
||||
|
||||
const updateState = appState.updates
|
||||
|
||||
function checkUpdateSafety(): boolean {
|
||||
let canUpdate: boolean
|
||||
if (appState.store.get(StoreKeys.BackupsDisabled)) {
|
||||
canUpdate = true
|
||||
} else {
|
||||
canUpdate = updateState.enableAutoUpdate && isLessThanOneHourFromNow(appState.lastBackupDate)
|
||||
}
|
||||
autoUpdater.autoInstallOnAppQuit = canUpdate
|
||||
autoUpdater.autoDownload = canUpdate
|
||||
return canUpdate
|
||||
}
|
||||
autorun(checkUpdateSafety)
|
||||
|
||||
const oneHour = 1 * 60 * 60 * 1000
|
||||
setInterval(checkUpdateSafety, oneHour)
|
||||
|
||||
autoUpdater.on('update-downloaded', (info: { version?: string }) => {
|
||||
window.webContents.send(MessageToWebApp.UpdateAvailable, null)
|
||||
updateState.autoUpdateHasBeenDownloaded(info.version || null)
|
||||
@@ -122,10 +105,9 @@ export function setupUpdates(window: BrowserWindow, appState: AppState, backupsM
|
||||
autoUpdater.on(MessageToWebApp.UpdateAvailable, (info: { version?: string }) => {
|
||||
updateState.checkedForUpdate(info.version || null)
|
||||
if (updateState.enableAutoUpdate) {
|
||||
const canUpdate = checkUpdateSafety()
|
||||
if (!canUpdate) {
|
||||
backupsManager.performBackup()
|
||||
}
|
||||
const canUpdate = updateState.enableAutoUpdate
|
||||
autoUpdater.autoInstallOnAppQuit = canUpdate
|
||||
autoUpdater.autoDownload = canUpdate
|
||||
}
|
||||
})
|
||||
autoUpdater.on('update-not-available', (info: { version?: string }) => {
|
||||
@@ -164,46 +146,21 @@ function quitAndInstall(window: BrowserWindow) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function isLessThanOneHourFromNow(date: number | null) {
|
||||
const now = Date.now()
|
||||
const onHourMs = 1 * 60 * 60 * 1000
|
||||
return now - (date ?? 0) < onHourMs
|
||||
}
|
||||
|
||||
export async function showUpdateInstallationDialog(parentWindow: BrowserWindow, appState: AppState): Promise<void> {
|
||||
if (!appState.updates.latestVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
if (appState.lastBackupDate && isLessThanOneHourFromNow(appState.lastBackupDate)) {
|
||||
const result = await dialog.showMessageBox(parentWindow, {
|
||||
type: 'info',
|
||||
title: str().updateReady.title,
|
||||
message: str().updateReady.message(appState.updates.latestVersion),
|
||||
buttons: [str().updateReady.installLater, str().updateReady.quitAndInstall],
|
||||
cancelId: 0,
|
||||
})
|
||||
const result = await dialog.showMessageBox(parentWindow, {
|
||||
type: 'info',
|
||||
title: str().updateReady.title,
|
||||
message: str().updateReady.message(appState.updates.latestVersion),
|
||||
buttons: [str().updateReady.installLater, str().updateReady.quitAndInstall],
|
||||
cancelId: 0,
|
||||
})
|
||||
|
||||
const buttonIndex = result.response
|
||||
if (buttonIndex === 1) {
|
||||
quitAndInstall(parentWindow)
|
||||
}
|
||||
} else {
|
||||
const cancelId = 0
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: str().updateReady.title,
|
||||
message: str().updateReady.noRecentBackupMessage,
|
||||
detail: str().updateReady.noRecentBackupDetail(appState.lastBackupDate),
|
||||
checkboxLabel: str().updateReady.noRecentBackupChecbox,
|
||||
checkboxChecked: false,
|
||||
buttons: [str().updateReady.installLater, str().updateReady.quitAndInstall],
|
||||
cancelId,
|
||||
})
|
||||
|
||||
if (!result.checkboxChecked || result.response === cancelId) {
|
||||
return
|
||||
}
|
||||
const buttonIndex = result.response
|
||||
if (buttonIndex === 1) {
|
||||
quitAndInstall(parentWindow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ export function debouncedJSONDiskWriter(durationMs: number, location: string, da
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
export async function openDirectoryPicker(): Promise<string | undefined> {
|
||||
export async function openDirectoryPicker(buttonLabel?: string): Promise<string | undefined> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'],
|
||||
buttonLabel: buttonLabel,
|
||||
})
|
||||
|
||||
return result.filePaths[0]
|
||||
@@ -63,6 +64,7 @@ export function writeJSONFileSync(filepath: string, data: unknown): void {
|
||||
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
/** Creates the directory if it doesn't exist. */
|
||||
export async function ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.promises.lstat(dirPath)
|
||||
@@ -251,7 +253,7 @@ export async function moveFiles(sources: string[], destDir: string): Promise<voi
|
||||
return Promise.all(sources.map((fileName) => moveFile(fileName, path.join(destDir, path.basename(fileName)))))
|
||||
}
|
||||
|
||||
async function moveFile(source: PathLike, destination: PathLike) {
|
||||
export async function moveFile(source: PathLike, destination: PathLike) {
|
||||
try {
|
||||
await fs.promises.rename(source, destination)
|
||||
} catch (_error) {
|
||||
@@ -261,6 +263,14 @@ async function moveFile(source: PathLike, destination: PathLike) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileIfExists(filePath: PathLike): Promise<void> {
|
||||
try {
|
||||
await deleteFile(filePath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a file, handling EPERM and EBUSY errors on Windows. */
|
||||
export async function deleteFile(filePath: PathLike): Promise<void> {
|
||||
for (let i = 1, maxTries = 10; i < maxTries; i++) {
|
||||
|
||||
@@ -6,8 +6,6 @@ import path from 'path'
|
||||
import { AppMessageType, MessageType } from '../../../test/TestIpcMessage'
|
||||
import { AppState } from '../../AppState'
|
||||
import { MessageToWebApp } from '../Shared/IpcMessages'
|
||||
import { createBackupsManager } from './Backups/BackupsManager'
|
||||
import { BackupsManagerInterface } from './Backups/BackupsManagerInterface'
|
||||
import { FilesBackupManager } from './FileBackups/FileBackupsManager'
|
||||
import { Keychain } from './Keychain/Keychain'
|
||||
import { MediaManager } from './Media/MediaManager'
|
||||
@@ -35,7 +33,6 @@ const WINDOW_MIN_HEIGHT = 400
|
||||
export interface WindowState {
|
||||
window: Electron.BrowserWindow
|
||||
menuManager: MenuManagerInterface
|
||||
backupsManager: BackupsManagerInterface
|
||||
trayManager: TrayManager
|
||||
}
|
||||
|
||||
@@ -64,7 +61,6 @@ export async function createWindowState({
|
||||
;(global as any).RemoteBridge = new RemoteBridge(
|
||||
window,
|
||||
Keychain,
|
||||
services.backupsManager,
|
||||
services.packageManager,
|
||||
services.searchManager,
|
||||
{
|
||||
@@ -93,7 +89,6 @@ export async function createWindowState({
|
||||
|
||||
window.on('blur', () => {
|
||||
window.webContents.send(MessageToWebApp.WindowBlurred, null)
|
||||
services.backupsManager.applicationDidBlur()
|
||||
})
|
||||
|
||||
window.once('ready-to-show', () => {
|
||||
@@ -201,8 +196,7 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
|
||||
const searchManager = initializeSearchManager(window.webContents)
|
||||
initializeZoomManager(window, appState.store)
|
||||
|
||||
const backupsManager = createBackupsManager(window.webContents, appState)
|
||||
const updateManager = setupUpdates(window, appState, backupsManager)
|
||||
const updateManager = setupUpdates(window, appState)
|
||||
const trayManager = createTrayManager(window, appState.store)
|
||||
const spellcheckerManager = createSpellcheckerManager(appState.store, window.webContents, appLocale)
|
||||
const mediaManager = new MediaManager()
|
||||
@@ -214,16 +208,14 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
|
||||
const menuManager = createMenuManager({
|
||||
appState,
|
||||
window,
|
||||
backupsManager,
|
||||
trayManager,
|
||||
store: appState.store,
|
||||
spellcheckerManager,
|
||||
})
|
||||
|
||||
const fileBackupsManager = new FilesBackupManager(appState)
|
||||
const fileBackupsManager = new FilesBackupManager(appState, window.webContents)
|
||||
|
||||
return {
|
||||
backupsManager,
|
||||
updateManager,
|
||||
trayManager,
|
||||
spellcheckerManager,
|
||||
|
||||
Reference in New Issue
Block a user