diff --git a/packages/desktop/.env.public.development b/packages/desktop/.env.public.development index bda521aaa..6361b5a6c 100644 --- a/packages/desktop/.env.public.development +++ b/packages/desktop/.env.public.development @@ -1,4 +1,4 @@ -PURCHASE_URL=https://website-dev.standardnotes.com/purchase -PLANS_URL=https://website-dev.standardnotes.com/plans -DASHBOARD_URL=https://website-dev.standardnotes.com/dashboard -DEFAULT_SYNC_SERVER=https://api-dev.standardnotes.com \ No newline at end of file +PURCHASE_URL=https://standardnotes.com/purchase +PLANS_URL=https://standardnotes.com/plans +DASHBOARD_URL=https://standardnotes.com/dashboard +DEFAULT_SYNC_SERVER=https://api.standardnotes.com \ No newline at end of file diff --git a/packages/desktop/README.md b/packages/desktop/README.md index abe9e9155..a79e5df87 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,51 +1,17 @@ -# Standard Notes - -
- -[![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes) - -
- -This application makes use of the core JS/CSS/HTML code found in the [web repo](https://github.com/standardnotes/app). For issues related to the actual app experience, please post issues in the web repo. +# Standard Notes Desktop App ## Running Locally -Make sure [Yarn](https://classic.yarnpkg.com/en/) is installed on your system. +Most commands below hog up a terminal process and must be conducted in different tabs. Be sure to quit any production version of the app running on your system first. ```bash yarn install -yarn build:web # Or `yarn dev:web` -yarn dev - -# In another terminal -yarn start +cd packages/snjs && yarn start # optional to watch snjs changes +cd packages/web && yarn watch # optional to watch web changes +yarn dev # to start compilation watch process for electron-related code +yarn start # to start dev app ``` -We use [commitlint](https://github.com/conventional-changelog/commitlint) to validate commit messages. -Before making a pull request, make sure to check the output of the following commands: - -```bash -yarn lint -yarn test # Make sure to start `yarn dev` before running the tests, and quit any running Standard Notes applications so they don't conflict. -``` - -Pull requests should target the `develop` branch. - -### Installing dependencies - -To determine where to install a dependency: - -- If it is only required for building, install it in `package.json`'s `devDependencies` -- If it is required at runtime but can be packaged by webpack, install it in `package.json`'s `dependencies`. -- If it must be distributed as a node module (not packaged by webpack), install it in `app/package.json`'s `dependencies` - - Also make sure to declare it as an external commonjs dependency in `webpack.common.js`. - -## Building - -Build for all platforms: - -- `yarn release` - ## Building natively on arm64 Building arm64 releases on amd64 systems is only possible with AppImage, Debian and universal "dir" targets. @@ -63,14 +29,6 @@ and making sure `$GEM_HOME/bin` is added to `$PATH`. Snap releases also require a working snapcraft / `snapd` installation. -Building can then be done by running: - -- `yarn install` - -Followed by - -- `node scripts/build.mjs deb-arm64` - ## Installation On Linux, download the latest AppImage from the [Releases](https://github.com/standardnotes/app/releases/latest) page, and give it executable permission: diff --git a/packages/desktop/app/AppState.ts b/packages/desktop/app/AppState.ts index a3386042f..ff9923c81 100644 --- a/packages/desktop/app/AppState.ts +++ b/packages/desktop/app/AppState.ts @@ -1,4 +1,3 @@ -import { action, makeObservable, observable } from 'mobx' import { MessageType } from '../test/TestIpcMessage' import { Store } from './javascripts/Main/Store/Store' import { StoreKeys } from './javascripts/Main/Store/StoreKeys' @@ -14,7 +13,6 @@ export class AppState { readonly startUrl = Urls.indexHtml readonly isPrimaryInstance: boolean public willQuitApp = false - public lastBackupDate: number | null = null public windowState?: WindowState public deepLinkUrl?: string public readonly updates: UpdateState @@ -28,11 +26,6 @@ export class AppState { this.lastRunVersion = this.store.get(StoreKeys.LastRunVersion) || 'unknown' this.store.set(StoreKeys.LastRunVersion, this.version) - makeObservable(this, { - lastBackupDate: observable, - setBackupCreationDate: action, - }) - this.updates = new UpdateState(this) if (isTesting()) { @@ -45,8 +38,4 @@ export class AppState { public isRunningVersionForFirstTime(): boolean { return this.lastRunVersion !== this.version } - - setBackupCreationDate(date: number | null): void { - this.lastBackupDate = date - } } diff --git a/packages/desktop/app/Logging.ts b/packages/desktop/app/Logging.ts new file mode 100644 index 000000000..fef8c6db6 --- /dev/null +++ b/packages/desktop/app/Logging.ts @@ -0,0 +1,21 @@ +import { isDev } from './javascripts/Main/Utils/Utils' +import { log as utilsLog } from '@standardnotes/utils' + +export const isDevMode = isDev() + +export enum LoggingDomain { + Backups, +} + +const LoggingStatus: Record = { + [LoggingDomain.Backups]: true, +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function log(domain: LoggingDomain, ...args: any[]): void { + if (!isDevMode || !LoggingStatus[domain]) { + return + } + + utilsLog(LoggingDomain[domain], ...args) +} diff --git a/packages/desktop/app/application.ts b/packages/desktop/app/application.ts index 0c5693d1e..de6f387df 100644 --- a/packages/desktop/app/application.ts +++ b/packages/desktop/app/application.ts @@ -29,6 +29,10 @@ export function initializeApplication(args: { app: Electron.App; ipcMain: Electr if (isDev()) { /** Expose the app's state as a global variable. Useful for debugging */ ;(global as any).appState = state + + setTimeout(() => { + state.windowState?.window.webContents.openDevTools() + }, 500) } } diff --git a/packages/desktop/app/index.ts b/packages/desktop/app/index.ts index 574aeddd4..b2a4e0a1b 100644 --- a/packages/desktop/app/index.ts +++ b/packages/desktop/app/index.ts @@ -120,7 +120,7 @@ function migrateSnapStorage() { error?.message ?? error, ) } - store.set(StoreKeys.BackupsLocation, newLocation) + store.set(StoreKeys.LegacyTextBackupsLocation, newLocation) console.log('Migration: finished moving backups directory.') } } diff --git a/packages/desktop/app/javascripts/Main/Backups/BackupsManager.ts b/packages/desktop/app/javascripts/Main/Backups/BackupsManager.ts deleted file mode 100644 index 2bcd863f9..000000000 --- a/packages/desktop/app/javascripts/Main/Backups/BackupsManager.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 - } -} diff --git a/packages/desktop/app/javascripts/Main/Backups/BackupsManagerInterface.ts b/packages/desktop/app/javascripts/Main/Backups/BackupsManagerInterface.ts deleted file mode 100644 index 28019813a..000000000 --- a/packages/desktop/app/javascripts/Main/Backups/BackupsManagerInterface.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface BackupsManagerInterface { - backupsAreEnabled: boolean - toggleBackupsStatus(): void - backupsLocation: string - backupsCount(): Promise - applicationDidBlur(): void - changeBackupsLocation(): void - beginBackups(): void - performBackup(): void - deleteBackups(): Promise - viewBackups(): void - saveBackupData(data: unknown): void -} diff --git a/packages/desktop/app/javascripts/Main/FileBackups/FileBackupsManager.ts b/packages/desktop/app/javascripts/Main/FileBackups/FileBackupsManager.ts index 5bd66fad5..988827040 100644 --- a/packages/desktop/app/javascripts/Main/FileBackups/FileBackupsManager.ts +++ b/packages/desktop/app/javascripts/Main/FileBackups/FileBackupsManager.ts @@ -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 = new Map() + private plaintextMappingCache?: PlaintextBackupsMapping - constructor(private appState: AppState) {} + constructor(private appState: AppState, private webContents: WebContents) {} - public isFilesBackupsEnabled(): Promise { - return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsEnabled)) - } + private async findUuidForPlaintextBackupFileName( + backupsDirectory: string, + targetFilename: string, + ): Promise { + const mapping = await this.getPlaintextBackupsMappingFile(backupsDirectory) - public async enableFilesBackups(): Promise { - 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 { - this.appState.store.set(StoreKeys.FileBackupsEnabled, false) + public async migrateLegacyFileBackupsToNewStructure(newLocation: string): Promise { + 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 { - const newPath = await openDirectoryPicker() + public async isLegacyFilesBackupsEnabled(): Promise { + return this.appState.store.get(StoreKeys.LegacyFileBackupsEnabled) + } - if (!newPath) { + async wasLegacyTextBackupsExplicitlyDisabled(): Promise { + const value = this.appState.store.get(StoreKeys.LegacyTextBackupsDisabled) + return value === true + } + + async getUserDocumentsDirectory(): Promise { + return Paths.documentsDir + } + + public async getLegacyFilesBackupsLocation(): Promise { + return this.appState.store.get(StoreKeys.LegacyFileBackupsLocation) + } + + async getLegacyTextBackupsLocation(): Promise { + 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 { + 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 { - 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 { - return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsLocation)) + private async getFileBackupsMappingFileFromDisk(backupsLocation: string): Promise { + return readJSONFile(this.getFileBackupsMappingFilePath(backupsLocation)) } - private getMappingFileLocation(): string { - const base = this.appState.store.get(StoreKeys.FileBackupsLocation) - return `${base}/info.json` - } - - private async getMappingFileFromDisk(): Promise { - return readJSONFile(this.getMappingFileLocation()) - } - - private defaultMappingFileValue(): FileBackupsMapping { + private defaulFileBackupstMappingFileValue(): FileBackupsMapping { return { version: FileBackupsConstantsV1.Version, files: {} } } - async getFilesBackupsMappingFile(): Promise { - const data = await this.getMappingFileFromDisk() + async getFilesBackupsMappingFile(backupsLocation: string): Promise { + 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 { - const location = await this.getFilesBackupsLocation() - + async openLocation(location: string): Promise { void shell.openPath(location) } - async openFileBackup(record: FileBackupRecord): Promise { - 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 { - const operation = new FileReadOperation(record) + async getFileBackupReadToken(filePath: string): Promise { + 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 { + let files = await fs.readdir(location) + files = files.filter((fileName) => fileName.endsWith(TextBackupFileExtension)) + return files.length + } + + async saveTextBackupData(location: string, data: string): Promise { + 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 { + return readJSONFile(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 { + 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 { + 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 { + if (!this.plaintextMappingCache) { + return + } + + await this.savePlaintextBackupsMappingFile(location, this.plaintextMappingCache) + } + + async monitorPlaintextBackupsLocationForChanges(backupsDirectory: string): Promise { + 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 + } + } } diff --git a/packages/desktop/app/javascripts/Main/FileBackups/FileReadOperation.ts b/packages/desktop/app/javascripts/Main/FileBackups/FileReadOperation.ts index 9f7919db0..dbc71ad4e 100644 --- a/packages/desktop/app/javascripts/Main/FileBackups/FileReadOperation.ts +++ b/packages/desktop/app/javascripts/Main/FileBackups/FileReadOperation.ts @@ -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 } diff --git a/packages/desktop/app/javascripts/Main/Menus/Menus.ts b/packages/desktop/app/javascripts/Main/Menus/Menus.ts index 47fd6d486..bfcca753b 100644 --- a/packages/desktop/app/javascripts/Main/Menus/Menus.ts +++ b/packages/desktop/app/javascripts/Main/Menus/Menus.ts @@ -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 diff --git a/packages/desktop/app/javascripts/Main/Remote/RemoteBridge.ts b/packages/desktop/app/javascripts/Main/Remote/RemoteBridge.ts index e26548a9a..2e120e8a6 100644 --- a/packages/desktop/app/javascripts/Main/Remote/RemoteBridge.ts +++ b/packages/desktop/app/javascripts/Main/Remote/RemoteBridge.ts @@ -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 { - return this.fileBackups.getFilesBackupsMappingFile() + getFilesBackupsMappingFile(location: string): Promise { + 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 { - return this.fileBackups.getFileBackupReadToken(record) + getFileBackupReadToken(filePath: string): Promise { + return this.fileBackups.getFileBackupReadToken(filePath) } readNextChunk(nextToken: string): Promise { return this.fileBackups.readNextChunk(nextToken) } - public isFilesBackupsEnabled(): Promise { - return this.fileBackups.isFilesBackupsEnabled() + public isLegacyFilesBackupsEnabled(): Promise { + return this.fileBackups.isLegacyFilesBackupsEnabled() } - public enableFilesBackups(): Promise { - return this.fileBackups.enableFilesBackups() + public getLegacyFilesBackupsLocation(): Promise { + return this.fileBackups.getLegacyFilesBackupsLocation() } - public disableFilesBackups(): Promise { - return this.fileBackups.disableFilesBackups() + wasLegacyTextBackupsExplicitlyDisabled(): Promise { + return this.fileBackups.wasLegacyTextBackupsExplicitlyDisabled() } - public changeFilesBackupsLocation(): Promise { - return this.fileBackups.changeFilesBackupsLocation() + getLegacyTextBackupsLocation(): Promise { + return this.fileBackups.getLegacyTextBackupsLocation() } - public getFilesBackupsLocation(): Promise { - return this.fileBackups.getFilesBackupsLocation() + saveTextBackupData(location: string, data: string): Promise { + return this.fileBackups.saveTextBackupData(location, data) } - public openFilesBackupsLocation(): Promise { - return this.fileBackups.openFilesBackupsLocation() + savePlaintextNoteBackup(location: string, uuid: string, name: string, tags: string[], data: string): Promise { + return this.fileBackups.savePlaintextNoteBackup(location, uuid, name, tags, data) } - public openFileBackup(record: FileBackupRecord): Promise { - return this.fileBackups.openFileBackup(record) + openLocation(path: string): Promise { + return this.fileBackups.openLocation(path) + } + + presentDirectoryPickerForLocationChangeAndTransferOld( + appendPath: string, + oldLocation?: string | undefined, + ): Promise { + return this.fileBackups.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation) + } + + getPlaintextBackupsMappingFile(location: string): Promise { + return this.fileBackups.getPlaintextBackupsMappingFile(location) + } + + persistPlaintextBackupsMappingFile(location: string): Promise { + return this.fileBackups.persistPlaintextBackupsMappingFile(location) + } + + getTextBackupsCount(location: string): Promise { + return this.fileBackups.getTextBackupsCount(location) + } + + migrateLegacyFileBackupsToNewStructure(newPath: string): Promise { + return this.fileBackups.migrateLegacyFileBackupsToNewStructure(newPath) + } + + getUserDocumentsDirectory(): Promise { + return this.fileBackups.getUserDocumentsDirectory() + } + + monitorPlaintextBackupsLocationForChanges(backupsDirectory: string): Promise { + return this.fileBackups.monitorPlaintextBackupsLocationForChanges(backupsDirectory) } askForMediaAccess(type: 'camera' | 'microphone'): Promise { diff --git a/packages/desktop/app/javascripts/Main/Store/StoreKeys.ts b/packages/desktop/app/javascripts/Main/Store/StoreKeys.ts index 92ee2e8e7..9d4ab8dd7 100644 --- a/packages/desktop/app/javascripts/Main/Store/StoreKeys.ts +++ b/packages/desktop/app/javascripts/Main/Store/StoreKeys.ts @@ -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 | null - [StoreKeys.FileBackupsEnabled]: boolean - [StoreKeys.FileBackupsLocation]: string [StoreKeys.LastRunVersion]: string + + [StoreKeys.LegacyTextBackupsLocation]: string + [StoreKeys.LegacyTextBackupsDisabled]: boolean + + [StoreKeys.LegacyFileBackupsEnabled]: boolean + [StoreKeys.LegacyFileBackupsLocation]: string } diff --git a/packages/desktop/app/javascripts/Main/Store/createSanitizedStoreData.ts b/packages/desktop/app/javascripts/Main/Store/createSanitizedStoreData.ts index 1be162284..6b59e9856 100644 --- a/packages/desktop/app/javascripts/Main/Store/createSanitizedStoreData.ts +++ b/packages/desktop/app/javascripts/Main/Store/createSanitizedStoreData.ts @@ -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 | null { if (!languages) { return null diff --git a/packages/desktop/app/javascripts/Main/Strings/english.ts b/packages/desktop/app/javascripts/Main/Strings/english.ts index 2880c216f..2cd1054e3 100644 --- a/packages/desktop/app/javascripts/Main/Strings/english.ts +++ b/packages/desktop/app/javascripts/Main/Strings/english.ts @@ -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) - ) - }, - }, } } diff --git a/packages/desktop/app/javascripts/Main/Strings/french.ts b/packages/desktop/app/javascripts/Main/Strings/french.ts index 99dff29b3..6be8911d7 100644 --- a/packages/desktop/app/javascripts/Main/Strings/french.ts +++ b/packages/desktop/app/javascripts/Main/Strings/french.ts @@ -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) - ) - }, - }, } } diff --git a/packages/desktop/app/javascripts/Main/Strings/index.ts b/packages/desktop/app/javascripts/Main/Strings/index.ts index 7f005c606..dd6fbc2c3 100644 --- a/packages/desktop/app/javascripts/Main/Strings/index.ts +++ b/packages/desktop/app/javascripts/Main/Strings/index.ts @@ -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() diff --git a/packages/desktop/app/javascripts/Main/Strings/types.ts b/packages/desktop/app/javascripts/Main/Strings/types.ts index 175fa0971..2c5649468 100644 --- a/packages/desktop/app/javascripts/Main/Strings/types.ts +++ b/packages/desktop/app/javascripts/Main/Strings/types.ts @@ -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 -} diff --git a/packages/desktop/app/javascripts/Main/Types/Paths.ts b/packages/desktop/app/javascripts/Main/Types/Paths.ts index 7fe29e505..c0a22e4de 100644 --- a/packages/desktop/app/javascripts/Main/Types/Paths.ts +++ b/packages/desktop/app/javascripts/Main/Types/Paths.ts @@ -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') }, diff --git a/packages/desktop/app/javascripts/Main/UpdateManager.ts b/packages/desktop/app/javascripts/Main/UpdateManager.ts index ab495ce2b..8aae005d0 100644 --- a/packages/desktop/app/javascripts/Main/UpdateManager.ts +++ b/packages/desktop/app/javascripts/Main/UpdateManager.ts @@ -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 { 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) } } diff --git a/packages/desktop/app/javascripts/Main/Utils/FileUtils.ts b/packages/desktop/app/javascripts/Main/Utils/FileUtils.ts index 7f2a67215..9bb0ff121 100644 --- a/packages/desktop/app/javascripts/Main/Utils/FileUtils.ts +++ b/packages/desktop/app/javascripts/Main/Utils/FileUtils.ts @@ -27,9 +27,10 @@ export function debouncedJSONDiskWriter(durationMs: number, location: string, da }, durationMs) } -export async function openDirectoryPicker(): Promise { +export async function openDirectoryPicker(buttonLabel?: string): Promise { 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 { try { const stat = await fs.promises.lstat(dirPath) @@ -251,7 +253,7 @@ export async function moveFiles(sources: string[], destDir: string): Promise 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 { + try { + await deleteFile(filePath) + } catch { + return + } +} + /** Deletes a file, handling EPERM and EBUSY errors on Windows. */ export async function deleteFile(filePath: PathLike): Promise { for (let i = 1, maxTries = 10; i < maxTries; i++) { diff --git a/packages/desktop/app/javascripts/Main/Window.ts b/packages/desktop/app/javascripts/Main/Window.ts index c16e4dc81..e63ca3a5a 100644 --- a/packages/desktop/app/javascripts/Main/Window.ts +++ b/packages/desktop/app/javascripts/Main/Window.ts @@ -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, diff --git a/packages/desktop/app/javascripts/Renderer/CrossProcessBridge.ts b/packages/desktop/app/javascripts/Renderer/CrossProcessBridge.ts index d6a59def8..d07cfb50d 100644 --- a/packages/desktop/app/javascripts/Renderer/CrossProcessBridge.ts +++ b/packages/desktop/app/javascripts/Renderer/CrossProcessBridge.ts @@ -3,52 +3,22 @@ import { Component } from '../Main/Packages/PackageManagerInterface' export interface CrossProcessBridge extends FileBackupsDevice { get extServerHost(): string - get useNativeKeychain(): boolean - get rendererPath(): string - get isMacOS(): boolean - get appVersion(): string - get useSystemMenuBar(): boolean - closeWindow(): void - minimizeWindow(): void - maximizeWindow(): void - unmaximizeWindow(): void - isWindowMaximized(): boolean - getKeychainValue(): Promise - setKeychainValue: (value: unknown) => Promise - clearKeychainValue(): Promise - - localBackupsCount(): Promise - - viewlocalBackups(): void - - deleteLocalBackups(): Promise - - saveDataBackup(data: unknown): void - displayAppMenu(): void - syncComponents(components: Component[]): void - - onMajorDataChange(): void - onSearch(text: string): void - - onInitialDataLoad(): void - destroyAllData(): void - askForMediaAccess(type: 'camera' | 'microphone'): Promise } diff --git a/packages/desktop/app/javascripts/Renderer/DesktopDevice.ts b/packages/desktop/app/javascripts/Renderer/DesktopDevice.ts index 42fbcfc93..9c04b6491 100644 --- a/packages/desktop/app/javascripts/Renderer/DesktopDevice.ts +++ b/packages/desktop/app/javascripts/Renderer/DesktopDevice.ts @@ -1,11 +1,11 @@ import { DesktopDeviceInterface, Environment, - FileBackupsMapping, RawKeychainValue, - FileBackupRecord, FileBackupReadToken, FileBackupReadChunkResponse, + FileBackupsMapping, + PlaintextBackupsMapping, } from '@web/Application/Device/DesktopSnjsExports' import { WebOrDesktopDevice } from '@web/Application/Device/WebOrDesktopDevice' import { Component } from '../Main/Packages/PackageManagerInterface' @@ -25,6 +25,33 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn super(appVersion) } + openLocation(path: string): Promise { + return this.remoteBridge.openLocation(path) + } + + presentDirectoryPickerForLocationChangeAndTransferOld( + appendPath: string, + oldLocation?: string | undefined, + ): Promise { + return this.remoteBridge.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation) + } + + getFilesBackupsMappingFile(location: string): Promise { + return this.remoteBridge.getFilesBackupsMappingFile(location) + } + + getPlaintextBackupsMappingFile(location: string): Promise { + return this.remoteBridge.getPlaintextBackupsMappingFile(location) + } + + persistPlaintextBackupsMappingFile(location: string): Promise { + return this.remoteBridge.persistPlaintextBackupsMappingFile(location) + } + + getTextBackupsCount(location: string): Promise { + return this.remoteBridge.getTextBackupsCount(location) + } + async getKeychainValue() { if (this.useNativeKeychain) { const keychainValue = await this.remoteBridge.getKeychainValue() @@ -57,18 +84,10 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn this.remoteBridge.syncComponents(components) } - onMajorDataChange() { - this.remoteBridge.onMajorDataChange() - } - onSearch(text: string) { this.remoteBridge.onSearch(text) } - onInitialDataLoad() { - this.remoteBridge.onInitialDataLoad() - } - async clearAllDataFromDevice(workspaceIdentifiers: string[]): Promise<{ killsApplication: boolean }> { await super.clearAllDataFromDevice(workspaceIdentifiers) @@ -77,69 +96,36 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn return { killsApplication: true } } - async downloadBackup() { - const receiver = window.webClient - - receiver.didBeginBackup() - - try { - const data = await receiver.requestBackupFile() - if (data) { - this.remoteBridge.saveDataBackup(data) - } else { - receiver.didFinishBackup(false) - } - } catch (error) { - console.error(error) - receiver.didFinishBackup(false) - } + public isLegacyFilesBackupsEnabled(): Promise { + return this.remoteBridge.isLegacyFilesBackupsEnabled() } - async localBackupsCount() { - return this.remoteBridge.localBackupsCount() + public getLegacyFilesBackupsLocation(): Promise { + return this.remoteBridge.getLegacyFilesBackupsLocation() } - viewlocalBackups() { - this.remoteBridge.viewlocalBackups() + wasLegacyTextBackupsExplicitlyDisabled(): Promise { + return this.remoteBridge.wasLegacyTextBackupsExplicitlyDisabled() } - async deleteLocalBackups() { - return this.remoteBridge.deleteLocalBackups() + getUserDocumentsDirectory(): Promise { + return this.remoteBridge.getUserDocumentsDirectory() } - public isFilesBackupsEnabled(): Promise { - return this.remoteBridge.isFilesBackupsEnabled() + getLegacyTextBackupsLocation(): Promise { + return this.remoteBridge.getLegacyTextBackupsLocation() } - public enableFilesBackups(): Promise { - return this.remoteBridge.enableFilesBackups() + saveTextBackupData(workspaceId: string, data: string): Promise { + return this.remoteBridge.saveTextBackupData(workspaceId, data) } - public disableFilesBackups(): Promise { - return this.remoteBridge.disableFilesBackups() - } - - public changeFilesBackupsLocation(): Promise { - return this.remoteBridge.changeFilesBackupsLocation() - } - - public getFilesBackupsLocation(): Promise { - return this.remoteBridge.getFilesBackupsLocation() - } - - async getFilesBackupsMappingFile(): Promise { - return this.remoteBridge.getFilesBackupsMappingFile() - } - - async openFilesBackupsLocation(): Promise { - return this.remoteBridge.openFilesBackupsLocation() - } - - openFileBackup(record: FileBackupRecord): Promise { - return this.remoteBridge.openFileBackup(record) + savePlaintextNoteBackup(location: string, uuid: string, name: string, tags: string[], data: string): Promise { + return this.remoteBridge.savePlaintextNoteBackup(location, uuid, name, tags, data) } async saveFilesBackupsFile( + location: string, uuid: string, metaFile: string, downloadRequest: { @@ -148,17 +134,25 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn url: string }, ): Promise<'success' | 'failed'> { - return this.remoteBridge.saveFilesBackupsFile(uuid, metaFile, downloadRequest) + return this.remoteBridge.saveFilesBackupsFile(location, uuid, metaFile, downloadRequest) } - getFileBackupReadToken(record: FileBackupRecord): Promise { - return this.remoteBridge.getFileBackupReadToken(record) + getFileBackupReadToken(filePath: string): Promise { + return this.remoteBridge.getFileBackupReadToken(filePath) + } + + migrateLegacyFileBackupsToNewStructure(newPath: string): Promise { + return this.remoteBridge.migrateLegacyFileBackupsToNewStructure(newPath) } readNextChunk(token: string): Promise { return this.remoteBridge.readNextChunk(token) } + monitorPlaintextBackupsLocationForChanges(backupsDirectory: string): Promise { + return this.remoteBridge.monitorPlaintextBackupsLocationForChanges(backupsDirectory) + } + async performHardReset(): Promise { console.error('performHardReset is not yet implemented') } diff --git a/packages/desktop/app/javascripts/Renderer/Preload.ts b/packages/desktop/app/javascripts/Renderer/Preload.ts index b58dd3cba..86fdb3c1b 100644 --- a/packages/desktop/app/javascripts/Renderer/Preload.ts +++ b/packages/desktop/app/javascripts/Renderer/Preload.ts @@ -1,28 +1,25 @@ -import { IpcRendererEvent } from 'electron/renderer' import { MessageToWebApp } from '../Shared/IpcMessages' +import { ElectronMainEvents, MainEventHandler } from '../Shared/ElectronMainEvents' const { ipcRenderer } = require('electron') const RemoteBridge = require('@electron/remote').getGlobal('RemoteBridge') const { contextBridge } = require('electron') -type MainEventCallback = (event: IpcRendererEvent, value: any) => void - process.once('loaded', function () { contextBridge.exposeInMainWorld('electronRemoteBridge', RemoteBridge.exposableValue) - contextBridge.exposeInMainWorld('electronMainEvents', { - handleUpdateAvailable: (callback: MainEventCallback) => ipcRenderer.on(MessageToWebApp.UpdateAvailable, callback), + const mainEvents: ElectronMainEvents = { + setUpdateAvailableHandler: (handler: MainEventHandler) => ipcRenderer.on(MessageToWebApp.UpdateAvailable, handler), - handlePerformAutomatedBackup: (callback: MainEventCallback) => - ipcRenderer.on(MessageToWebApp.PerformAutomatedBackup, callback), + setWindowBlurredHandler: (handler: MainEventHandler) => ipcRenderer.on(MessageToWebApp.WindowBlurred, handler), - handleFinishedSavingBackup: (callback: MainEventCallback) => - ipcRenderer.on(MessageToWebApp.FinishedSavingBackup, callback), + setWindowFocusedHandler: (handler: MainEventHandler) => ipcRenderer.on(MessageToWebApp.WindowFocused, handler), - handleWindowBlurred: (callback: MainEventCallback) => ipcRenderer.on(MessageToWebApp.WindowBlurred, callback), + setWatchedDirectoriesChangeHandler: (handler: MainEventHandler) => + ipcRenderer.on(MessageToWebApp.WatchedDirectoriesChanges, handler), - handleWindowFocused: (callback: MainEventCallback) => ipcRenderer.on(MessageToWebApp.WindowFocused, callback), + setInstallComponentCompleteHandler: (handler: MainEventHandler) => + ipcRenderer.on(MessageToWebApp.InstallComponentComplete, handler), + } - handleInstallComponentComplete: (callback: MainEventCallback) => - ipcRenderer.on(MessageToWebApp.InstallComponentComplete, callback), - }) + contextBridge.exposeInMainWorld('electronMainEvents', mainEvents) }) diff --git a/packages/desktop/app/javascripts/Renderer/Renderer.ts b/packages/desktop/app/javascripts/Renderer/Renderer.ts index 04a4c2b66..0ac4d98ac 100644 --- a/packages/desktop/app/javascripts/Renderer/Renderer.ts +++ b/packages/desktop/app/javascripts/Renderer/Renderer.ts @@ -1,8 +1,12 @@ -import { DesktopClientRequiresWebMethods } from '@web/Application/Device/DesktopSnjsExports' +import { + DesktopClientRequiresWebMethods, + DesktopWatchedDirectoriesChanges, +} from '@web/Application/Device/DesktopSnjsExports' import { StartApplication } from '@web/Application/Device/StartApplication' import { IpcRendererEvent } from 'electron/renderer' import { CrossProcessBridge } from './CrossProcessBridge' import { DesktopDevice } from './DesktopDevice' +import { ElectronMainEvents } from '../Shared/ElectronMainEvents' declare const DEFAULT_SYNC_SERVER: string declare const WEBSOCKET_URL: string @@ -23,7 +27,7 @@ declare global { purchaseUrl: string startApplication: StartApplication zip: unknown - electronMainEvents: any + electronMainEvents: ElectronMainEvents } } @@ -128,26 +132,22 @@ async function configureWindow(remoteBridge: CrossProcessBridge) { } } -window.electronMainEvents.handleUpdateAvailable(() => { +window.electronMainEvents.setUpdateAvailableHandler(() => { window.webClient.updateAvailable() }) -window.electronMainEvents.handlePerformAutomatedBackup(() => { - void window.device.downloadBackup() -}) - -window.electronMainEvents.handleFinishedSavingBackup((_: IpcRendererEvent, data: { success: boolean }) => { - window.webClient.didFinishBackup(data.success) -}) - -window.electronMainEvents.handleWindowBlurred(() => { +window.electronMainEvents.setWindowBlurredHandler(() => { window.webClient.windowLostFocus() }) -window.electronMainEvents.handleWindowFocused(() => { +window.electronMainEvents.setWindowFocusedHandler(() => { window.webClient.windowGainedFocus() }) -window.electronMainEvents.handleInstallComponentComplete((_: IpcRendererEvent, data: any) => { +window.electronMainEvents.setInstallComponentCompleteHandler((_: IpcRendererEvent, data: any) => { void window.webClient.onComponentInstallationComplete(data.component, undefined) }) + +window.electronMainEvents.setWatchedDirectoriesChangeHandler((_: IpcRendererEvent, changes: unknown) => { + void window.webClient.handleWatchedDirectoriesChanges(changes as DesktopWatchedDirectoriesChanges) +}) diff --git a/packages/desktop/app/javascripts/Shared/ElectronMainEvents.ts b/packages/desktop/app/javascripts/Shared/ElectronMainEvents.ts new file mode 100644 index 000000000..fcc4cffcd --- /dev/null +++ b/packages/desktop/app/javascripts/Shared/ElectronMainEvents.ts @@ -0,0 +1,11 @@ +import { IpcRendererEvent } from 'electron/renderer' + +export type MainEventHandler = (event: IpcRendererEvent, value: unknown) => void + +export interface ElectronMainEvents { + setUpdateAvailableHandler(handler: MainEventHandler): void + setWindowBlurredHandler(handler: MainEventHandler): void + setWindowFocusedHandler(handler: MainEventHandler): void + setInstallComponentCompleteHandler(handler: MainEventHandler): void + setWatchedDirectoriesChangeHandler(handler: MainEventHandler): void +} diff --git a/packages/desktop/app/javascripts/Shared/IpcMessages.ts b/packages/desktop/app/javascripts/Shared/IpcMessages.ts index 300669b0f..5b2a48a2e 100644 --- a/packages/desktop/app/javascripts/Shared/IpcMessages.ts +++ b/packages/desktop/app/javascripts/Shared/IpcMessages.ts @@ -1,10 +1,9 @@ export enum MessageToWebApp { UpdateAvailable = 'update-available', - PerformAutomatedBackup = 'download-backup', - FinishedSavingBackup = 'finished-saving-backup', WindowBlurred = 'window-blurred', WindowFocused = 'window-focused', InstallComponentComplete = 'install-component-complete', + WatchedDirectoriesChanges = 'watched-directories-changes', } export enum MessageToMainProcess { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 76b62e860..6cc8c6942 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -23,7 +23,7 @@ "format": "prettier --write .", "lint:eslint": "eslint app/index.ts app/application.ts app/javascripts/**/*.ts", "lint:formatting": "prettier --check app", - "lint": "yarn lint:formatting && yarn lint:eslint app", + "lint": "yarn lint:formatting && yarn lint:eslint app && yarn tsc", "tsc": "tsc --noEmit", "release:mac": "node scripts/build.mjs mac", "start": "electron ./app --enable-logging --icon _icon/icon.png", diff --git a/packages/files/src/Domain/Device/DesktopWatchedChanges.ts b/packages/files/src/Domain/Device/DesktopWatchedChanges.ts new file mode 100644 index 000000000..b29e4e9ca --- /dev/null +++ b/packages/files/src/Domain/Device/DesktopWatchedChanges.ts @@ -0,0 +1,8 @@ +export type DesktopWatchedDirectoriesChange = { + itemUuid: string + path: string + type: 'rename' | 'change' + content: string +} + +export type DesktopWatchedDirectoriesChanges = DesktopWatchedDirectoriesChange[] diff --git a/packages/files/src/Domain/Device/FileBackupsDevice.ts b/packages/files/src/Domain/Device/FileBackupsDevice.ts index d0a9bb208..72fafdbaf 100644 --- a/packages/files/src/Domain/Device/FileBackupsDevice.ts +++ b/packages/files/src/Domain/Device/FileBackupsDevice.ts @@ -1,12 +1,44 @@ import { FileDownloadProgress } from '../Types/FileDownloadProgress' -import { FileBackupRecord, FileBackupsMapping } from './FileBackupsMapping' +import { FileBackupsMapping } from './FileBackupsMapping' + +type PlaintextNoteRecord = { + tag?: string + path: string +} + +type UuidString = string +export type PlaintextBackupsMapping = { + version: string + files: Record +} + +export interface FileBackupsDevice + extends FileBackupsMethods, + LegacyBackupsMethods, + PlaintextBackupsMethods, + TextBackupsMethods { + openLocation(path: string): Promise + + /** + * The reason we combine presenting a directory picker and transfering old files to the new location + * in one function is so we don't have to expose a general `transferDirectories` function to the web app, + * which would give it too much power. + * @param appendPath The path to append to the selected directory. + */ + presentDirectoryPickerForLocationChangeAndTransferOld( + appendPath: string, + oldLocation?: string, + ): Promise + + monitorPlaintextBackupsLocationForChanges(backupsDirectory: string): Promise +} export type FileBackupReadToken = string export type FileBackupReadChunkResponse = { chunk: Uint8Array; isLast: boolean; progress: FileDownloadProgress } - -export interface FileBackupsDevice { - getFilesBackupsMappingFile(): Promise +interface FileBackupsMethods { + getFilesBackupsMappingFile(location: string): Promise saveFilesBackupsFile( + location: string, uuid: string, metaFile: string, downloadRequest: { @@ -15,13 +47,26 @@ export interface FileBackupsDevice { url: string }, ): Promise<'success' | 'failed'> - getFileBackupReadToken(record: FileBackupRecord): Promise + getFileBackupReadToken(filePath: string): Promise readNextChunk(token: string): Promise - isFilesBackupsEnabled(): Promise - enableFilesBackups(): Promise - disableFilesBackups(): Promise - changeFilesBackupsLocation(): Promise - getFilesBackupsLocation(): Promise - openFilesBackupsLocation(): Promise - openFileBackup(record: FileBackupRecord): Promise +} + +interface PlaintextBackupsMethods { + getPlaintextBackupsMappingFile(location: string): Promise + persistPlaintextBackupsMappingFile(location: string): Promise + savePlaintextNoteBackup(location: string, uuid: string, name: string, tags: string[], data: string): Promise +} + +interface TextBackupsMethods { + getTextBackupsCount(location: string): Promise + saveTextBackupData(location: string, data: string): Promise + getUserDocumentsDirectory(): Promise +} + +interface LegacyBackupsMethods { + migrateLegacyFileBackupsToNewStructure(newPath: string): Promise + isLegacyFilesBackupsEnabled(): Promise + getLegacyFilesBackupsLocation(): Promise + wasLegacyTextBackupsExplicitlyDisabled(): Promise + getLegacyTextBackupsLocation(): Promise } diff --git a/packages/files/src/Domain/Device/FileBackupsMapping.ts b/packages/files/src/Domain/Device/FileBackupsMapping.ts index a2b7ff1ec..713a3198a 100644 --- a/packages/files/src/Domain/Device/FileBackupsMapping.ts +++ b/packages/files/src/Domain/Device/FileBackupsMapping.ts @@ -2,7 +2,6 @@ import { FileBackupsConstantsV1 } from './FileBackupsConstantsV1' export type FileBackupRecord = { backedUpOn: Date - absolutePath: string relativePath: string metadataFileName: typeof FileBackupsConstantsV1.MetadataFileName binaryFileName: typeof FileBackupsConstantsV1.BinaryFileName diff --git a/packages/files/src/Domain/Service/BackupServiceInterface.ts b/packages/files/src/Domain/Service/BackupServiceInterface.ts index 0ca310e3e..c00e9e1c3 100644 --- a/packages/files/src/Domain/Service/BackupServiceInterface.ts +++ b/packages/files/src/Domain/Service/BackupServiceInterface.ts @@ -1,14 +1,35 @@ import { OnChunkCallback } from '../Chunker/OnChunkCallback' +import { DesktopWatchedDirectoriesChanges } from '../Device/DesktopWatchedChanges' import { FileBackupRecord } from '../Device/FileBackupsMapping' export interface BackupServiceInterface { + openAllDirectoriesContainingBackupFiles(): void + prependWorkspacePathForPath(path: string): string + importWatchedDirectoryChanges(changes: DesktopWatchedDirectoriesChanges): Promise + getFileBackupInfo(file: { uuid: string }): Promise readEncryptedFileFromBackup(uuid: string, onChunk: OnChunkCallback): Promise<'success' | 'failed' | 'aborted'> - isFilesBackupsEnabled(): Promise + isFilesBackupsEnabled(): boolean enableFilesBackups(): Promise - disableFilesBackups(): Promise + disableFilesBackups(): void changeFilesBackupsLocation(): Promise - getFilesBackupsLocation(): Promise + getFilesBackupsLocation(): string | undefined openFilesBackupsLocation(): Promise openFileBackup(record: FileBackupRecord): Promise + getFileBackupAbsolutePath(record: FileBackupRecord): string + + isTextBackupsEnabled(): boolean + enableTextBackups(): Promise + disableTextBackups(): void + getTextBackupsLocation(): string | undefined + openTextBackupsLocation(): Promise + changeTextBackupsLocation(): Promise + saveTextBackupData(data: string): Promise + + isPlaintextBackupsEnabled(): boolean + enablePlaintextBackups(): Promise + disablePlaintextBackups(): void + getPlaintextBackupsLocation(): string | undefined + openPlaintextBackupsLocation(): Promise + changePlaintextBackupsLocation(): Promise } diff --git a/packages/files/src/Domain/index.ts b/packages/files/src/Domain/index.ts index d1f3bfc57..1c7e7be80 100644 --- a/packages/files/src/Domain/index.ts +++ b/packages/files/src/Domain/index.ts @@ -1,30 +1,31 @@ export * from './Api/DirectoryHandle' export * from './Api/FileHandleRead' export * from './Api/FileHandleReadWrite' +export * from './Api/FilesApiInterface' export * from './Api/FileSystemApi' export * from './Api/FileSystemNoSelection' export * from './Api/FileSystemResult' -export * from './Api/FilesApiInterface' export * from './Cache/FileMemoryCache' export * from './Chunker/ByteChunker' export * from './Chunker/OnChunkCallback' export * from './Chunker/OrderedByteChunker' +export * from './Device/DesktopWatchedChanges' export * from './Device/FileBackupMetadataFile' export * from './Device/FileBackupsConstantsV1' export * from './Device/FileBackupsDevice' export * from './Device/FileBackupsMapping' +export * from './Operations/DownloadAndDecrypt' +export * from './Operations/EncryptAndUpload' export * from './Service/BackupServiceInterface' export * from './Service/FilesClientInterface' export * from './Service/ReadAndDecryptBackupFileFileSystemAPI' export * from './Service/ReadAndDecryptBackupFileUsingBackupService' -export * from './Operations/DownloadAndDecrypt' -export * from './Operations/EncryptAndUpload' -export * from './UseCase/FileDecryptor' -export * from './UseCase/FileUploader' -export * from './UseCase/FileEncryptor' -export * from './UseCase/FileDownloader' export * from './Types/DecryptedBytes' export * from './Types/EncryptedBytes' export * from './Types/FileDownloadProgress' export * from './Types/FileUploadProgress' export * from './Types/FileUploadResult' +export * from './UseCase/FileDecryptor' +export * from './UseCase/FileDownloader' +export * from './UseCase/FileEncryptor' +export * from './UseCase/FileUploader' diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index ac3cd08b2..a3217a32a 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -637,7 +637,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: 60195509584153283780abdac5569feffb8f08cc @@ -658,7 +658,7 @@ SPEC CHECKSUMS: MMKV: 9c4663aa7ca255d478ff10f2f5cb7d17c1651ccd MMKVCore: 89f5c8a66bba2dcd551779dea4d412eeec8ff5bb OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 + RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: bec48f07daf7bcdc2655a0cde84e07d24d2a9e2a RCTTypeSafety: 171394eebacf71e1cfad79dbfae7ee8fc16ca80a React: d7433ccb6a8c36e4cbed59a73c0700fc83c3e98a diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index 32ee1c7c0..2ff20ca42 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -1,6 +1,6 @@ import { ApplicationIdentifier, ContentType } from '@standardnotes/common' import { BackupFile, DecryptedItemInterface, ItemStream, Platform, PrefKey, PrefValue } from '@standardnotes/models' -import { FilesClientInterface } from '@standardnotes/files' +import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files' import { AlertService } from '../Alert/AlertService' import { ComponentManagerInterface } from '../Component/ComponentManagerInterface' @@ -50,6 +50,7 @@ export interface ApplicationInterface { get user(): UserClientInterface get files(): FilesClientInterface get subscriptions(): SubscriptionClientInterface + get fileBackups(): BackupServiceInterface | undefined readonly identifier: ApplicationIdentifier readonly platform: Platform deviceInterface: DeviceInterface diff --git a/packages/services/src/Domain/Backups/BackupService.spec.ts b/packages/services/src/Domain/Backups/BackupService.spec.ts index 635eb8c4f..b918d287c 100644 --- a/packages/services/src/Domain/Backups/BackupService.spec.ts +++ b/packages/services/src/Domain/Backups/BackupService.spec.ts @@ -1,3 +1,7 @@ +import { HistoryServiceInterface } from './../History/HistoryServiceInterface' +import { PayloadManagerInterface } from './../Payloads/PayloadManagerInterface' +import { StorageServiceInterface } from './../Storage/StorageServiceInterface' +import { SessionsClientInterface } from './../Session/SessionsClientInterface' import { StatusServiceInterface } from './../Status/StatusServiceInterface' import { FilesBackupService } from './BackupService' import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common' @@ -20,6 +24,10 @@ describe('backup service', () => { let internalEventBus: InternalEventBusInterface let backupService: FilesBackupService let device: FileBackupsDevice + let session: SessionsClientInterface + let storage: StorageServiceInterface + let payloads: PayloadManagerInterface + let history: HistoryServiceInterface beforeEach(() => { apiService = {} as jest.Mocked @@ -41,6 +49,8 @@ describe('backup service', () => { device.getFileBackupReadToken = jest.fn() device.readNextChunk = jest.fn() + session = {} as jest.Mocked + syncService = {} as jest.Mocked syncService.sync = jest.fn() @@ -55,7 +65,25 @@ describe('backup service', () => { internalEventBus = {} as jest.Mocked internalEventBus.publish = jest.fn() - backupService = new FilesBackupService(itemManager, apiService, encryptor, device, status, crypto, internalEventBus) + payloads = {} as PayloadManagerInterface + history = {} as HistoryServiceInterface + + storage = {} as StorageServiceInterface + storage.getValue = jest.fn().mockReturnValue('') + + backupService = new FilesBackupService( + itemManager, + apiService, + encryptor, + device, + status, + crypto, + storage, + session, + payloads, + history, + internalEventBus, + ) crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({ state: {}, diff --git a/packages/services/src/Domain/Backups/BackupService.ts b/packages/services/src/Domain/Backups/BackupService.ts index 2ab240e14..54534d8a9 100644 --- a/packages/services/src/Domain/Backups/BackupService.ts +++ b/packages/services/src/Domain/Backups/BackupService.ts @@ -1,6 +1,15 @@ +import { ApplicationStage } from './../Application/ApplicationStage' import { ContentType } from '@standardnotes/common' import { EncryptionProviderInterface } from '@standardnotes/encryption' -import { PayloadEmitSource, FileItem, CreateEncryptedBackupFileContextPayload } from '@standardnotes/models' +import { + PayloadEmitSource, + FileItem, + CreateEncryptedBackupFileContextPayload, + SNNote, + SNTag, + isNote, + NoteContent, +} from '@standardnotes/models' import { ClientDisplayableError } from '@standardnotes/responses' import { FilesApiInterface, @@ -10,16 +19,28 @@ import { FileBackupRecord, OnChunkCallback, BackupServiceInterface, + DesktopWatchedDirectoriesChanges, } from '@standardnotes/files' import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' import { ItemManagerInterface } from '../Item/ItemManagerInterface' import { AbstractService } from '../Service/AbstractService' import { StatusServiceInterface } from '../Status/StatusServiceInterface' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' -import { log, LoggingDomain } from '../Logging' +import { StorageServiceInterface } from '../Storage/StorageServiceInterface' +import { StorageKey } from '../Storage/StorageKeys' +import { SessionsClientInterface } from '../Session/SessionsClientInterface' +import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface' +import { HistoryServiceInterface } from '../History/HistoryServiceInterface' + +const PlaintextBackupsDirectoryName = 'Plaintext Backups' +export const TextBackupsDirectoryName = 'Text Backups' +export const FileBackupsDirectoryName = 'File Backups' export class FilesBackupService extends AbstractService implements BackupServiceInterface { - private itemsObserverDisposer: () => void + private filesObserverDisposer: () => void + private notesObserverDisposer: () => void + private tagsObserverDisposer: () => void + private pendingFiles = new Set() private mappingCache?: FileBackupsMapping['files'] @@ -30,45 +51,259 @@ export class FilesBackupService extends AbstractService implements BackupService private device: FileBackupsDevice, private status: StatusServiceInterface, private crypto: PureCryptoInterface, + private storage: StorageServiceInterface, + private session: SessionsClientInterface, + private payloads: PayloadManagerInterface, + private history: HistoryServiceInterface, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) - this.itemsObserverDisposer = items.addObserver(ContentType.File, ({ changed, inserted, source }) => { + this.filesObserverDisposer = items.addObserver(ContentType.File, ({ changed, inserted, source }) => { const applicableSources = [ PayloadEmitSource.LocalDatabaseLoaded, PayloadEmitSource.RemoteSaved, PayloadEmitSource.RemoteRetrieved, ] - if (applicableSources.includes(source)) { void this.handleChangedFiles([...changed, ...inserted]) } }) + + const noteAndTagSources = [ + PayloadEmitSource.RemoteSaved, + PayloadEmitSource.RemoteRetrieved, + PayloadEmitSource.OfflineSyncSaved, + ] + + this.notesObserverDisposer = items.addObserver(ContentType.Note, ({ changed, inserted, source }) => { + if (noteAndTagSources.includes(source)) { + void this.handleChangedNotes([...changed, ...inserted]) + } + }) + + this.tagsObserverDisposer = items.addObserver(ContentType.Tag, ({ changed, inserted, source }) => { + if (noteAndTagSources.includes(source)) { + void this.handleChangedTags([...changed, ...inserted]) + } + }) + } + + async importWatchedDirectoryChanges(changes: DesktopWatchedDirectoriesChanges): Promise { + for (const change of changes) { + const existingItem = this.items.findItem(change.itemUuid) + if (!existingItem) { + continue + } + + if (!isNote(existingItem)) { + continue + } + + const newContent: NoteContent = { + ...existingItem.payload.content, + preview_html: undefined, + preview_plain: undefined, + text: change.content, + } + + const payloadCopy = existingItem.payload.copy({ + content: newContent, + }) + + await this.payloads.importPayloads([payloadCopy], this.history.getHistoryMapCopy()) + } } override deinit() { super.deinit() - this.itemsObserverDisposer() + this.filesObserverDisposer() + this.notesObserverDisposer() + this.tagsObserverDisposer() ;(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 + ;(this.storage as unknown) = undefined + ;(this.session as unknown) = undefined } - public isFilesBackupsEnabled(): Promise { - return this.device.isFilesBackupsEnabled() + override async handleApplicationStage(stage: ApplicationStage): Promise { + if (stage === ApplicationStage.Launched_10) { + void this.automaticallyEnableTextBackupsIfPreferenceNotSet() + } + } + + private async automaticallyEnableTextBackupsIfPreferenceNotSet(): Promise { + if (this.storage.getValue(StorageKey.TextBackupsEnabled) == undefined) { + this.storage.setValue(StorageKey.TextBackupsEnabled, true) + const location = `${await this.device.getUserDocumentsDirectory()}/${this.prependWorkspacePathForPath( + TextBackupsDirectoryName, + )}` + this.storage.setValue(StorageKey.TextBackupsLocation, location) + } + } + + openAllDirectoriesContainingBackupFiles(): void { + const fileBackupsLocation = this.getFilesBackupsLocation() + const plaintextBackupsLocation = this.getPlaintextBackupsLocation() + const textBackupsLocation = this.getTextBackupsLocation() + + if (fileBackupsLocation) { + void this.device.openLocation(fileBackupsLocation) + } + + if (plaintextBackupsLocation) { + void this.device.openLocation(plaintextBackupsLocation) + } + + if (textBackupsLocation) { + void this.device.openLocation(textBackupsLocation) + } + } + + isFilesBackupsEnabled(): boolean { + return this.storage.getValue(StorageKey.FileBackupsEnabled, undefined, false) + } + + getFilesBackupsLocation(): string | undefined { + return this.storage.getValue(StorageKey.FileBackupsLocation) + } + + isTextBackupsEnabled(): boolean { + return this.storage.getValue(StorageKey.TextBackupsEnabled, undefined, true) + } + + prependWorkspacePathForPath(path: string): string { + const workspacePath = this.session.getWorkspaceDisplayIdentifier() + + return `${workspacePath}/${path}` + } + + async enableTextBackups(): Promise { + let location = this.getTextBackupsLocation() + if (!location) { + location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld( + this.prependWorkspacePathForPath(TextBackupsDirectoryName), + ) + if (!location) { + return + } + } + + this.storage.setValue(StorageKey.TextBackupsEnabled, true) + this.storage.setValue(StorageKey.TextBackupsLocation, location) + } + + disableTextBackups(): void { + this.storage.setValue(StorageKey.TextBackupsEnabled, false) + } + + getTextBackupsLocation(): string | undefined { + return this.storage.getValue(StorageKey.TextBackupsLocation) + } + + async openTextBackupsLocation(): Promise { + const location = this.getTextBackupsLocation() + if (location) { + void this.device.openLocation(location) + } + } + + async changeTextBackupsLocation(): Promise { + const oldLocation = this.getTextBackupsLocation() + const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld( + this.prependWorkspacePathForPath(TextBackupsDirectoryName), + oldLocation, + ) + + if (!newLocation) { + return undefined + } + + this.storage.setValue(StorageKey.TextBackupsLocation, newLocation) + + return newLocation + } + + async saveTextBackupData(data: string): Promise { + const location = this.getTextBackupsLocation() + if (!location) { + return + } + + return this.device.saveTextBackupData(location, data) + } + + isPlaintextBackupsEnabled(): boolean { + return this.storage.getValue(StorageKey.PlaintextBackupsEnabled, undefined, false) + } + + public async enablePlaintextBackups(): Promise { + let location = this.getPlaintextBackupsLocation() + if (!location) { + location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld( + this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName), + ) + if (!location) { + return + } + } + + this.storage.setValue(StorageKey.PlaintextBackupsEnabled, true) + this.storage.setValue(StorageKey.PlaintextBackupsLocation, location) + + void this.handleChangedNotes(this.items.getItems(ContentType.Note)) + } + + disablePlaintextBackups(): void { + this.storage.setValue(StorageKey.PlaintextBackupsEnabled, false) + this.storage.setValue(StorageKey.PlaintextBackupsLocation, undefined) + } + + getPlaintextBackupsLocation(): string | undefined { + return this.storage.getValue(StorageKey.PlaintextBackupsLocation) + } + + async openPlaintextBackupsLocation(): Promise { + const location = this.getPlaintextBackupsLocation() + if (location) { + void this.device.openLocation(location) + } + } + + async changePlaintextBackupsLocation(): Promise { + const oldLocation = this.getPlaintextBackupsLocation() + const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld( + this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName), + oldLocation, + ) + + if (!newLocation) { + return undefined + } + + this.storage.setValue(StorageKey.PlaintextBackupsLocation, newLocation) + + return newLocation } public async enableFilesBackups(): Promise { - await this.device.enableFilesBackups() - - if (!(await this.isFilesBackupsEnabled())) { - return + let location = this.getFilesBackupsLocation() + if (!location) { + location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld( + this.prependWorkspacePathForPath(FileBackupsDirectoryName), + ) + if (!location) { + return + } } + this.storage.setValue(StorageKey.FileBackupsEnabled, true) + this.storage.setValue(StorageKey.FileBackupsLocation, location) + this.backupAllFiles() } @@ -78,24 +313,39 @@ export class FilesBackupService extends AbstractService implements BackupService void this.handleChangedFiles(files) } - public disableFilesBackups(): Promise { - return this.device.disableFilesBackups() + public disableFilesBackups(): void { + this.storage.setValue(StorageKey.FileBackupsEnabled, false) } - public changeFilesBackupsLocation(): Promise { - return this.device.changeFilesBackupsLocation() + public async changeFilesBackupsLocation(): Promise { + const oldLocation = this.getFilesBackupsLocation() + const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld( + this.prependWorkspacePathForPath(FileBackupsDirectoryName), + oldLocation, + ) + if (!newLocation) { + return undefined + } + + this.storage.setValue(StorageKey.FileBackupsLocation, newLocation) + + return newLocation } - public getFilesBackupsLocation(): Promise { - return this.device.getFilesBackupsLocation() + public async openFilesBackupsLocation(): Promise { + const location = this.getFilesBackupsLocation() + if (location) { + void this.device.openLocation(location) + } } - public openFilesBackupsLocation(): Promise { - return this.device.openFilesBackupsLocation() - } + private async getBackupsMappingFromDisk(): Promise { + const location = this.getFilesBackupsLocation() + if (!location) { + return undefined + } - private async getBackupsMappingFromDisk(): Promise { - const result = (await this.device.getFilesBackupsMappingFile()).files + const result = (await this.device.getFilesBackupsMappingFile(location)).files this.mappingCache = result @@ -106,30 +356,39 @@ export class FilesBackupService extends AbstractService implements BackupService this.mappingCache = undefined } - private async getBackupsMappingFromCache(): Promise { + private async getBackupsMappingFromCache(): Promise { return this.mappingCache ?? (await this.getBackupsMappingFromDisk()) } public async getFileBackupInfo(file: { uuid: string }): Promise { const mapping = await this.getBackupsMappingFromCache() + if (!mapping) { + return undefined + } + const record = mapping[file.uuid] return record } + public getFileBackupAbsolutePath(record: FileBackupRecord): string { + const location = this.getFilesBackupsLocation() + return `${location}/${record.relativePath}` + } + public async openFileBackup(record: FileBackupRecord): Promise { - await this.device.openFileBackup(record) + const location = this.getFileBackupAbsolutePath(record) + await this.device.openLocation(location) } private async handleChangedFiles(files: FileItem[]): Promise { - if (files.length === 0) { - return - } - - if (!(await this.isFilesBackupsEnabled())) { + if (files.length === 0 || !this.isFilesBackupsEnabled()) { return } const mapping = await this.getBackupsMappingFromDisk() + if (!mapping) { + throw new ClientDisplayableError('No backups mapping found') + } for (const file of files) { if (this.pendingFiles.has(file.uuid)) { @@ -150,6 +409,36 @@ export class FilesBackupService extends AbstractService implements BackupService this.invalidateMappingCache() } + private async handleChangedNotes(notes: SNNote[]): Promise { + if (notes.length === 0 || !this.isPlaintextBackupsEnabled()) { + return + } + + const location = this.getPlaintextBackupsLocation() + if (!location) { + throw new ClientDisplayableError('No plaintext backups location found') + } + + for (const note of notes) { + const tags = this.items.getSortedTagsForItem(note) + const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag)) + await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, note.text) + } + + await this.device.persistPlaintextBackupsMappingFile(location) + } + + private async handleChangedTags(tags: SNTag[]): Promise { + if (tags.length === 0 || !this.isPlaintextBackupsEnabled()) { + return + } + + for (const tag of tags) { + const notes = this.items.referencesForItem(tag, ContentType.Note) + await this.handleChangedNotes(notes) + } + } + async readEncryptedFileFromBackup(uuid: string, onChunk: OnChunkCallback): Promise<'success' | 'failed' | 'aborted'> { const fileBackup = await this.getFileBackupInfo({ uuid }) @@ -157,7 +446,8 @@ export class FilesBackupService extends AbstractService implements BackupService return 'failed' } - const token = await this.device.getFileBackupReadToken(fileBackup) + const path = `${this.getFilesBackupsLocation()}/${fileBackup.relativePath}/${fileBackup.binaryFileName}` + const token = await this.device.getFileBackupReadToken(path) let readMore = true let index = 0 @@ -176,7 +466,10 @@ export class FilesBackupService extends AbstractService implements BackupService } private async performBackupOperation(file: FileItem): Promise<'success' | 'failed' | 'aborted'> { - log(LoggingDomain.FilesBackups, 'Backing up file locally', file.uuid) + const location = this.getFilesBackupsLocation() + if (!location) { + return 'failed' + } const messageId = this.status.addMessage(`Backing up file ${file.name}...`) @@ -189,6 +482,7 @@ export class FilesBackupService extends AbstractService implements BackupService const itemsKey = this.items.getDisplayableItemsKeys().find((k) => k.uuid === encryptedFile.items_key_id) if (!itemsKey) { + this.status.removeMessage(messageId) return 'failed' } @@ -201,6 +495,7 @@ export class FilesBackupService extends AbstractService implements BackupService const token = await this.api.createFileValetToken(file.remoteIdentifier, 'read') if (token instanceof ClientDisplayableError) { + this.status.removeMessage(messageId) return 'failed' } @@ -218,7 +513,7 @@ export class FilesBackupService extends AbstractService implements BackupService const metaFileAsString = JSON.stringify(metaFile, null, 2) - const result = await this.device.saveFilesBackupsFile(file.uuid, metaFileAsString, { + const result = await this.device.saveFilesBackupsFile(location, file.uuid, metaFileAsString, { chunkSizes: file.encryptedChunkSizes, url: this.api.getFilesDownloadUrl(), valetToken: token, @@ -235,4 +530,18 @@ export class FilesBackupService extends AbstractService implements BackupService return result } + + /** + * Not presently used or enabled. It works, but presently has the following edge cases: + * 1. Editing the note directly in SN triggers an immediate backup which triggers a file change which triggers the observer + * 2. Since changes are based on filenames, a note with the same title as another may not properly map to the correct uuid + * 3. Opening the file triggers a watch event from Node's watch API. + * 4. Gives web code ability to monitor arbitrary locations. Needs whitelisting mechanism. + */ + disabledExperimental_monitorPlaintextBackups(): void { + const location = this.getPlaintextBackupsLocation() + if (location) { + void this.device.monitorPlaintextBackupsLocationForChanges(location) + } + } } diff --git a/packages/services/src/Domain/Device/DesktopWebCommunication.ts b/packages/services/src/Domain/Device/DesktopWebCommunication.ts index f4502452b..23176b55a 100644 --- a/packages/services/src/Domain/Device/DesktopWebCommunication.ts +++ b/packages/services/src/Domain/Device/DesktopWebCommunication.ts @@ -1,23 +1,11 @@ import { DecryptedTransferPayload } from '@standardnotes/models' -import { FileBackupsDevice } from '@standardnotes/files' +import { DesktopWatchedDirectoriesChanges, FileBackupsDevice } from '@standardnotes/files' export interface WebClientRequiresDesktopMethods extends FileBackupsDevice { - localBackupsCount(): Promise - - viewlocalBackups(): void - - deleteLocalBackups(): Promise - syncComponents(payloads: unknown[]): void - onMajorDataChange(): void - - onInitialDataLoad(): void - onSearch(text?: string): void - downloadBackup(): void | Promise - get extensionsServerHost(): string askForMediaAccess(type: 'camera' | 'microphone'): Promise @@ -32,9 +20,5 @@ export interface DesktopClientRequiresWebMethods { onComponentInstallationComplete(componentData: DecryptedTransferPayload, error: unknown): Promise - requestBackupFile(): Promise - - didBeginBackup(): void - - didFinishBackup(success: boolean): void + handleWatchedDirectoriesChanges(changes: DesktopWatchedDirectoriesChanges): Promise } diff --git a/packages/services/src/Domain/History/HistoryServiceInterface.ts b/packages/services/src/Domain/History/HistoryServiceInterface.ts new file mode 100644 index 000000000..dbfe65a23 --- /dev/null +++ b/packages/services/src/Domain/History/HistoryServiceInterface.ts @@ -0,0 +1,5 @@ +import { HistoryMap } from '@standardnotes/models' + +export interface HistoryServiceInterface { + getHistoryMapCopy(): HistoryMap +} diff --git a/packages/services/src/Domain/Item/ItemManagerInterface.ts b/packages/services/src/Domain/Item/ItemManagerInterface.ts index 0a7058a44..95fde44fa 100644 --- a/packages/services/src/Domain/Item/ItemManagerInterface.ts +++ b/packages/services/src/Domain/Item/ItemManagerInterface.ts @@ -12,6 +12,7 @@ import { ItemContent, PredicateInterface, DecryptedPayload, + SNTag, } from '@standardnotes/models' import { AbstractService } from '../Service/AbstractService' @@ -96,4 +97,11 @@ export interface ItemManagerInterface extends AbstractService { subItemsMatchingPredicates(items: T[], predicates: PredicateInterface[]): T[] removeAllItemsFromMemory(): Promise getDirtyItems(): (DecryptedItemInterface | DeletedItemInterface)[] + getTagLongTitle(tag: SNTag): string + getSortedTagsForItem(item: DecryptedItemInterface): SNTag[] + referencesForItem( + itemToLookupUuidFor: DecryptedItemInterface, + contentType?: ContentType, + ): I[] + findItem(uuid: string): T | undefined } diff --git a/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts b/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts index c619829e6..d72ce4d06 100644 --- a/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts +++ b/packages/services/src/Domain/Payloads/PayloadManagerInterface.ts @@ -3,6 +3,8 @@ import { EncryptedPayloadInterface, FullyFormedPayloadInterface, PayloadEmitSource, + DecryptedPayloadInterface, + HistoryMap, } from '@standardnotes/models' import { IntegrityPayload } from '@standardnotes/responses' @@ -21,4 +23,6 @@ export interface PayloadManagerInterface { * Returns a detached array of all items which are not deleted */ get nonDeletedItems(): FullyFormedPayloadInterface[] + + importPayloads(payloads: DecryptedPayloadInterface[], historyMap: HistoryMap): Promise } diff --git a/packages/services/src/Domain/Session/SessionsClientInterface.ts b/packages/services/src/Domain/Session/SessionsClientInterface.ts index b7303fbdf..ef61a58a1 100644 --- a/packages/services/src/Domain/Session/SessionsClientInterface.ts +++ b/packages/services/src/Domain/Session/SessionsClientInterface.ts @@ -8,6 +8,7 @@ import { Base64String } from '@standardnotes/sncrypto-common' import { SessionManagerResponse } from './SessionManagerResponse' export interface SessionsClientInterface { + getWorkspaceDisplayIdentifier(): string populateSessionFromDemoShareToken(token: Base64String): Promise getUser(): User | undefined isCurrentSessionReadOnly(): boolean | undefined diff --git a/packages/services/src/Domain/Storage/StorageKeys.ts b/packages/services/src/Domain/Storage/StorageKeys.ts index 55ce4ba6b..5413906dd 100644 --- a/packages/services/src/Domain/Storage/StorageKeys.ts +++ b/packages/services/src/Domain/Storage/StorageKeys.ts @@ -42,6 +42,12 @@ export enum StorageKey { LaunchPriorityUuids = 'launch_priority_uuids', LastReadChangelogVersion = 'last_read_changelog_version', MomentsEnabled = 'moments_enabled', + TextBackupsEnabled = 'text_backups_enabled', + TextBackupsLocation = 'text_backups_location', + PlaintextBackupsEnabled = 'plaintext_backups_enabled', + PlaintextBackupsLocation = 'plaintext_backups_location', + FileBackupsEnabled = 'file_backups_enabled', + FileBackupsLocation = 'file_backups_location', } export enum NonwrappedStorageKey { diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index 7c0cc0d67..544b5cd77 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -4,19 +4,21 @@ export * from './Application/AppGroupManagedApplication' export * from './Application/ApplicationInterface' export * from './Application/ApplicationStage' export * from './Application/DeinitCallback' -export * from './Application/DeinitSource' export * from './Application/DeinitMode' +export * from './Application/DeinitSource' +export * from './Application/WebApplicationInterface' export * from './Auth/AuthClientInterface' export * from './Auth/AuthManager' export * from './Authenticator/AuthenticatorClientInterface' export * from './Authenticator/AuthenticatorManager' -export * from './User/UserClientInterface' -export * from './Application/WebApplicationInterface' export * from './Backups/BackupService' export * from './Challenge' export * from './Component/ComponentManagerInterface' export * from './Component/ComponentViewerError' export * from './Component/ComponentViewerInterface' +export * from './Device/DatabaseItemMetadata' +export * from './Device/DatabaseLoadOptions' +export * from './Device/DatabaseLoadSorter' export * from './Device/DesktopDeviceInterface' export * from './Device/DesktopManagerInterface' export * from './Device/DesktopWebCommunication' @@ -24,9 +26,6 @@ export * from './Device/DeviceInterface' export * from './Device/MobileDeviceInterface' export * from './Device/TypeCheck' export * from './Device/WebOrDesktopDeviceInterface' -export * from './Device/DatabaseLoadOptions' -export * from './Device/DatabaseItemMetadata' -export * from './Device/DatabaseLoadSorter' export * from './Diagnostics/ServiceDiagnostics' export * from './Encryption/BackupFileDecryptor' export * from './Encryption/EncryptionService' @@ -40,12 +39,13 @@ export * from './Event/EventObserver' export * from './Event/SyncEvent' export * from './Event/SyncEventReceiver' export * from './Event/WebAppEvent' -export * from './Feature/FeatureStatus' export * from './Feature/FeaturesClientInterface' export * from './Feature/FeaturesEvent' +export * from './Feature/FeatureStatus' export * from './Feature/OfflineSubscriptionEntitlements' export * from './Feature/SetOfflineFeaturesFunctionResponse' export * from './Files/FileService' +export * from './History/HistoryServiceInterface' export * from './Integrity/IntegrityApiInterface' export * from './Integrity/IntegrityEvent' export * from './Integrity/IntegrityEventPayload' @@ -59,9 +59,9 @@ export * from './Internal/InternalEventType' export * from './Item/ItemCounter' export * from './Item/ItemCounterInterface' export * from './Item/ItemManagerInterface' +export * from './Item/ItemRelationshipDirection' export * from './Item/ItemsClientInterface' export * from './Item/ItemsServerInterface' -export * from './Item/ItemRelationshipDirection' export * from './Mutator/MutatorClientInterface' export * from './Payloads/PayloadManagerInterface' export * from './Preferences/PreferenceServiceInterface' @@ -76,21 +76,22 @@ export * from './Session/SessionManagerResponse' export * from './Session/SessionsClientInterface' export * from './Status/StatusService' export * from './Status/StatusServiceInterface' -export * from './Storage/StorageKeys' export * from './Storage/InMemoryStore' export * from './Storage/KeyValueStoreInterface' +export * from './Storage/StorageKeys' export * from './Storage/StorageServiceInterface' export * from './Storage/StorageTypes' export * from './Strings/InfoStrings' export * from './Strings/Messages' -export * from './Subscription/SubscriptionClientInterface' -export * from './Subscription/SubscriptionManager' export * from './Subscription/AppleIAPProductId' export * from './Subscription/AppleIAPReceipt' +export * from './Subscription/SubscriptionClientInterface' +export * from './Subscription/SubscriptionManager' export * from './Sync/SyncMode' export * from './Sync/SyncOptions' export * from './Sync/SyncQueueStrategy' export * from './Sync/SyncServiceInterface' export * from './Sync/SyncSource' export * from './User/UserClientInterface' +export * from './User/UserClientInterface' export * from './User/UserService' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index df596877c..c2d698eaf 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -188,7 +188,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private declare _getRevision: GetRevision private declare _deleteRevision: DeleteRevision - private internalEventBus!: ExternalServices.InternalEventBusInterface + public internalEventBus!: ExternalServices.InternalEventBusInterface private eventHandlers: ApplicationObserver[] = [] // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1184,13 +1184,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createSettingsService() this.createFeaturesService() this.createComponentManager() - this.createMigrationService() this.createMfaService() this.createStatusService() if (isDesktopDevice(this.deviceInterface)) { this.createFilesBackupService(this.deviceInterface) } + this.createMigrationService() this.createFileService() this.createIntegrityService() @@ -1381,6 +1381,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli identifier: this.identifier, internalEventBus: this.internalEventBus, legacySessionStorageMapper: this.legacySessionStorageMapper, + backups: this.fileBackups, }) this.services.push(this.migrationService) } @@ -1584,6 +1585,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.httpService, this.sessionStorageMapper, this.legacySessionStorageMapper, + this.identifier, this.internalEventBus, ) this.serviceObservers.push( @@ -1761,6 +1763,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli device, this.statusService, this.options.crypto, + this.storage, + this.sessions, + this.payloadManager, + this.historyManager, this.internalEventBus, ) this.services.push(this.filesBackupService) diff --git a/packages/snjs/lib/Migrations/MigrationServices.ts b/packages/snjs/lib/Migrations/MigrationServices.ts index f774515bf..d6c46feb6 100644 --- a/packages/snjs/lib/Migrations/MigrationServices.ts +++ b/packages/snjs/lib/Migrations/MigrationServices.ts @@ -1,6 +1,6 @@ +import { BackupServiceInterface } from '@standardnotes/files' import { Environment } from '@standardnotes/models' import { DeviceInterface, InternalEventBusInterface, EncryptionService } from '@standardnotes/services' - import { SNSessionManager } from '../Services/Session/SessionManager' import { ApplicationIdentifier } from '@standardnotes/common' import { ItemManager } from '@Lib/Services/Items/ItemManager' @@ -13,6 +13,7 @@ export type MigrationServices = { storageService: DiskStorageService challengeService: ChallengeService sessionManager: SNSessionManager + backups?: BackupServiceInterface itemManager: ItemManager singletonManager: SNSingletonManager featuresService: SNFeaturesService diff --git a/packages/snjs/lib/Migrations/Versions/2_167_6.ts b/packages/snjs/lib/Migrations/Versions/2_167_6.ts new file mode 100644 index 000000000..fab93a362 --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_167_6.ts @@ -0,0 +1,51 @@ +import { + ApplicationStage, + FileBackupsDirectoryName, + StorageKey, + TextBackupsDirectoryName, + isDesktopDevice, +} from '@standardnotes/services' +import { Migration } from '@Lib/Migrations/Migration' + +export class Migration2_167_6 extends Migration { + static override version(): string { + return '2.167.6' + } + + protected registerStageHandlers(): void { + this.registerStageHandler(ApplicationStage.Launched_10, async () => { + await this.migrateStorageKeysForDesktopBackups() + this.markDone() + }) + } + + private async migrateStorageKeysForDesktopBackups(): Promise { + const device = this.services.deviceInterface + if (!isDesktopDevice(device) || !this.services.backups) { + return + } + + const fileBackupsEnabled = await device.isLegacyFilesBackupsEnabled() + this.services.storageService.setValue(StorageKey.FileBackupsEnabled, fileBackupsEnabled) + + if (fileBackupsEnabled) { + const legacyLocation = await device.getLegacyFilesBackupsLocation() + const newLocation = `${legacyLocation}/${this.services.backups.prependWorkspacePathForPath( + FileBackupsDirectoryName, + )}` + await device.migrateLegacyFileBackupsToNewStructure(newLocation) + this.services.storageService.setValue(StorageKey.FileBackupsLocation, newLocation) + } + + const wasLegacyDisabled = await device.wasLegacyTextBackupsExplicitlyDisabled() + if (wasLegacyDisabled) { + this.services.storageService.setValue(StorageKey.TextBackupsEnabled, false) + } else { + const newTextBackupsLocation = `${await device.getLegacyTextBackupsLocation()}/${this.services.backups.prependWorkspacePathForPath( + TextBackupsDirectoryName, + )}` + this.services.storageService.setValue(StorageKey.TextBackupsLocation, newTextBackupsLocation) + this.services.storageService.setValue(StorageKey.TextBackupsEnabled, true) + } + } +} diff --git a/packages/snjs/lib/Migrations/Versions/README.md b/packages/snjs/lib/Migrations/Versions/README.md new file mode 100644 index 000000000..3283234dd --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/README.md @@ -0,0 +1,5 @@ +## To create a migration: + +1. Create a new file inside versions specifiying the would-be version of SNJS that would result when publishing your migration. For example, if the current SNJS version is 1.0.0 in package.json, your migration version should be 1.0.1 to target users below this version. + +2. **Important** Export your migration inside the index.ts file. diff --git a/packages/snjs/lib/Migrations/Versions/index.ts b/packages/snjs/lib/Migrations/Versions/index.ts index 15f5de331..8c4f99d89 100644 --- a/packages/snjs/lib/Migrations/Versions/index.ts +++ b/packages/snjs/lib/Migrations/Versions/index.ts @@ -3,7 +3,15 @@ import { Migration2_7_0 } from './2_7_0' import { Migration2_20_0 } from './2_20_0' import { Migration2_36_0 } from './2_36_0' import { Migration2_42_0 } from './2_42_0' +import { Migration2_167_6 } from './2_167_6' -export const MigrationClasses = [Migration2_0_15, Migration2_7_0, Migration2_20_0, Migration2_36_0, Migration2_42_0] +export const MigrationClasses = [ + Migration2_0_15, + Migration2_7_0, + Migration2_20_0, + Migration2_36_0, + Migration2_42_0, + Migration2_167_6, +] -export { Migration2_0_15, Migration2_7_0, Migration2_20_0, Migration2_36_0, Migration2_42_0 } +export { Migration2_0_15, Migration2_7_0, Migration2_20_0, Migration2_36_0, Migration2_42_0, Migration2_167_6 } diff --git a/packages/snjs/lib/Services/History/HistoryManager.ts b/packages/snjs/lib/Services/History/HistoryManager.ts index fd6c4b7be..0ef205282 100644 --- a/packages/snjs/lib/Services/History/HistoryManager.ts +++ b/packages/snjs/lib/Services/History/HistoryManager.ts @@ -5,7 +5,12 @@ import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService' import { UuidString } from '../../Types/UuidString' import * as Models from '@standardnotes/models' import { SNNote } from '@standardnotes/models' -import { AbstractService, DeviceInterface, InternalEventBusInterface } from '@standardnotes/services' +import { + AbstractService, + DeviceInterface, + HistoryServiceInterface, + InternalEventBusInterface, +} from '@standardnotes/services' /** The amount of revisions per item above which should call for an optimization. */ const DefaultItemRevisionsThreshold = 20 @@ -25,7 +30,7 @@ const LargeEntryDeltaThreshold = 25 * 2. Remote server history. Entries are automatically added by the server and must be * retrieved per item via an API call. */ -export class SNHistoryManager extends AbstractService { +export class SNHistoryManager extends AbstractService implements HistoryServiceInterface { private removeChangeObserver: () => void /** diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index eee33c739..ddb7fcc70 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -343,13 +343,13 @@ export class ItemManager /** * Returns all items that an item directly references */ - public referencesForItem( + public referencesForItem( itemToLookupUuidFor: Models.DecryptedItemInterface, contentType?: ContentType, - ): Models.DecryptedItemInterface[] { - const item = this.findSureItem(itemToLookupUuidFor.uuid) + ): I[] { + const item = this.findSureItem(itemToLookupUuidFor.uuid) const uuids = item.references.map((ref) => ref.uuid) - let references = this.findItems(uuids) + let references = this.findItems(uuids) if (contentType) { references = references.filter((ref) => { return ref?.content_type === contentType diff --git a/packages/snjs/lib/Services/Migration/MigrationService.ts b/packages/snjs/lib/Services/Migration/MigrationService.ts index a6d5381a3..e413e9469 100644 --- a/packages/snjs/lib/Services/Migration/MigrationService.ts +++ b/packages/snjs/lib/Services/Migration/MigrationService.ts @@ -54,10 +54,7 @@ export class SNMigrationService extends AbstractService { await this.markMigrationsAsDone() }) } else { - await this.services.deviceInterface.setRawStorageValue( - namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion), - SnjsVersion, - ) + await this.markMigrationsAsDone() } } diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index 8e5a9a546..e8c2c3191 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -101,6 +101,7 @@ export class SNSessionManager private httpService: HttpServiceInterface, private sessionStorageMapper: MapperInterface>, private legacySessionStorageMapper: MapperInterface>, + private workspaceIdentifier: string, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -130,6 +131,14 @@ export class SNSessionManager super.deinit() } + public getWorkspaceDisplayIdentifier(): string { + if (this.user) { + return this.user.email + } else { + return this.workspaceIdentifier + } + } + private setUser(user?: User) { this.user = user this.apiService.setUser(user) diff --git a/packages/snjs/mocha/migrations/migration.test.js b/packages/snjs/mocha/migrations/migration.test.js index 544fe566f..abe4ca7ce 100644 --- a/packages/snjs/mocha/migrations/migration.test.js +++ b/packages/snjs/mocha/migrations/migration.test.js @@ -3,7 +3,7 @@ chai.use(chaiAsPromised) const expect = chai.expect describe('migrations', () => { - const allMigrations = ['2.0.15', '2.7.0', '2.20.0', '2.36.0', '2.42.0'] + const allMigrations = ['2.0.15', '2.7.0', '2.20.0', '2.36.0', '2.42.0', '2.167.6'] beforeEach(async () => { localStorage.clear() diff --git a/packages/snjs/package.json b/packages/snjs/package.json index 3e375b272..68c5e2489 100644 --- a/packages/snjs/package.json +++ b/packages/snjs/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/snjs", - "version": "2.167.5", + "version": "2.167.6", "engines": { "node": ">=16.0.0 <17.0.0" }, diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index 5220dbc26..0c3a4a155 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -17,12 +17,12 @@ import { WebApplicationInterface, MobileDeviceInterface, MobileUnlockTiming, - InternalEventBus, DecryptedItem, EditorIdentifier, FeatureIdentifier, Environment, ApplicationOptionsDefaults, + BackupServiceInterface, } from '@standardnotes/snjs' import { makeObservable, observable } from 'mobx' import { startAuthentication, startRegistration } from '@simplewebauthn/browser' @@ -93,27 +93,26 @@ export class WebApplication extends SNApplication implements WebApplicationInter }) deviceInterface.setApplication(this) - const internalEventBus = new InternalEventBus() this.itemControllerGroup = new ItemGroupController(this) - this.routeService = new RouteService(this, internalEventBus) + this.routeService = new RouteService(this, this.internalEventBus) this.webServices = {} as WebServices this.webServices.keyboardService = new KeyboardService(platform, this.environment) this.webServices.archiveService = new ArchiveManager(this) - this.webServices.themeService = new ThemeManager(this, internalEventBus) + this.webServices.themeService = new ThemeManager(this, this.internalEventBus) this.webServices.autolockService = this.isNativeMobileWeb() ? undefined - : new AutolockService(this, internalEventBus) + : new AutolockService(this, this.internalEventBus) this.webServices.desktopService = isDesktopDevice(deviceInterface) - ? new DesktopManager(this, deviceInterface) + ? new DesktopManager(this, deviceInterface, this.fileBackups as BackupServiceInterface) : undefined this.webServices.viewControllerManager = new ViewControllerManager(this, deviceInterface) this.webServices.changelogService = new ChangelogService(this.environment, this.storage) this.webServices.momentsService = new MomentsService( this, this.webServices.viewControllerManager.filesController, - internalEventBus, + this.internalEventBus, ) if (this.isNativeMobileWeb()) { @@ -181,6 +180,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter for (const observer of this.webEventObservers) { observer(event, data) } + + this.internalEventBus.publish({ type: event, payload: data }) } publishPanelDidResizeEvent(name: string, width: number, collapsed: boolean) { @@ -268,16 +269,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter return this.protocolUpgradeAvailable() } - downloadBackup(): void | Promise { - if (isDesktopDevice(this.deviceInterface)) { - return this.deviceInterface.downloadBackup() - } - } - - async signOutAndDeleteLocalBackups(): Promise { - isDesktopDevice(this.deviceInterface) && (await this.deviceInterface.deleteLocalBackups()) - - return this.user.signOut() + performDesktopTextBackup(): void | Promise { + return this.getDesktopService()?.saveDesktopBackup() } isGlobalSpellcheckEnabled(): boolean { diff --git a/packages/web/src/javascripts/Application/Device/DesktopManager.ts b/packages/web/src/javascripts/Application/Device/DesktopManager.ts index 2f08f1366..d8402df00 100644 --- a/packages/web/src/javascripts/Application/Device/DesktopManager.ts +++ b/packages/web/src/javascripts/Application/Device/DesktopManager.ts @@ -14,6 +14,8 @@ import { DesktopDeviceInterface, WebApplicationInterface, WebAppEvent, + BackupServiceInterface, + DesktopWatchedDirectoriesChanges, } from '@standardnotes/snjs' export class DesktopManager @@ -27,10 +29,34 @@ export class DesktopManager dataLoaded = false lastSearchedText?: string - constructor(application: WebApplicationInterface, private device: DesktopDeviceInterface) { + private textBackupsInterval: ReturnType | undefined + private needsInitialTextBackup = false + + constructor( + application: WebApplicationInterface, + private device: DesktopDeviceInterface, + private backups: BackupServiceInterface, + ) { super(application, new InternalEventBus()) } + async handleWatchedDirectoriesChanges(changes: DesktopWatchedDirectoriesChanges): Promise { + void this.backups.importWatchedDirectoryChanges(changes) + } + + beginTextBackupsTimer() { + if (this.textBackupsInterval) { + clearInterval(this.textBackupsInterval) + } + + this.needsInitialTextBackup = true + + const hoursInterval = 12 + const seconds = hoursInterval * 60 * 60 + const milliseconds = seconds * 1000 + this.textBackupsInterval = setInterval(this.saveDesktopBackup, milliseconds) + } + get webApplication() { return this.application as WebApplicationInterface } @@ -44,14 +70,35 @@ export class DesktopManager super.onAppEvent(eventName).catch(console.error) if (eventName === ApplicationEvent.LocalDataLoaded) { this.dataLoaded = true - this.device.onInitialDataLoad() + if (this.backups.isTextBackupsEnabled()) { + this.beginTextBackupsTimer() + } } else if (eventName === ApplicationEvent.MajorDataChange) { - this.device.onMajorDataChange() + void this.saveDesktopBackup() } } - saveBackup() { - this.device.onMajorDataChange() + async saveDesktopBackup() { + this.webApplication.notifyWebEvent(WebAppEvent.BeganBackupDownload) + + const data = await this.getBackupFile() + if (data) { + await this.webApplication.fileBackups?.saveTextBackupData(data) + this.webApplication.notifyWebEvent(WebAppEvent.EndedBackupDownload, { success: true }) + } + } + + private async getBackupFile(): Promise { + const encrypted = this.application.hasProtectionSources() + const data = encrypted + ? await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + : await this.application.createDecryptedBackupFile() + + if (data) { + return JSON.stringify(data, null, 2) + } + + return undefined } getExtServerHost(): string { @@ -111,6 +158,11 @@ export class DesktopManager windowLostFocus(): void { this.webApplication.notifyWebEvent(WebAppEvent.WindowDidBlur) + + if (this.needsInitialTextBackup) { + this.needsInitialTextBackup = false + void this.saveDesktopBackup() + } } async onComponentInstallationComplete(componentData: DecryptedTransferPayload) { @@ -136,25 +188,4 @@ export class DesktopManager observer.callback(updatedComponent as SNComponent) } } - - async requestBackupFile(): Promise { - const encrypted = this.application.hasProtectionSources() - const data = encrypted - ? await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() - : await this.application.createDecryptedBackupFile() - - if (data) { - return JSON.stringify(data, null, 2) - } - - return undefined - } - - didBeginBackup() { - this.webApplication.notifyWebEvent(WebAppEvent.BeganBackupDownload) - } - - didFinishBackup(success: boolean) { - this.webApplication.notifyWebEvent(WebAppEvent.EndedBackupDownload, { success }) - } } diff --git a/packages/web/src/javascripts/Application/Device/DesktopSnjsExports.ts b/packages/web/src/javascripts/Application/Device/DesktopSnjsExports.ts index b1aa8cce5..a290d8c3c 100644 --- a/packages/web/src/javascripts/Application/Device/DesktopSnjsExports.ts +++ b/packages/web/src/javascripts/Application/Device/DesktopSnjsExports.ts @@ -10,4 +10,7 @@ export { FileBackupReadToken, FileBackupReadChunkResponse, FileDownloadProgress, + PlaintextBackupsMapping, + DesktopWatchedDirectoriesChanges, + DesktopWatchedDirectoriesChange, } from '@standardnotes/snjs' diff --git a/packages/web/src/javascripts/Components/ConfirmSignoutModal/ConfirmSignoutModal.tsx b/packages/web/src/javascripts/Components/ConfirmSignoutModal/ConfirmSignoutModal.tsx index 409259481..0c8eaafe3 100644 --- a/packages/web/src/javascripts/Components/ConfirmSignoutModal/ConfirmSignoutModal.tsx +++ b/packages/web/src/javascripts/Components/ConfirmSignoutModal/ConfirmSignoutModal.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import { FunctionComponent, useCallback, useRef } from 'react' import { STRING_SIGN_OUT_CONFIRMATION } from '@/Constants/Strings' import { WebApplication } from '@/Application/Application' import { ViewControllerManager } from '@/Controllers/ViewControllerManager' @@ -8,6 +8,7 @@ import { isDesktopApplication } from '@/Utils' import Button from '@/Components/Button/Button' import Icon from '../Icon/Icon' import AlertDialog from '../AlertDialog/AlertDialog' +import HorizontalSeparator from '../Shared/HorizontalSeparator' type Props = { application: WebApplication @@ -16,30 +17,24 @@ type Props = { } const ConfirmSignoutModal: FunctionComponent = ({ application, viewControllerManager, applicationGroup }) => { - const [deleteLocalBackups, setDeleteLocalBackups] = useState(false) + const hasAnyBackupsEnabled = + application.fileBackups?.isFilesBackupsEnabled() || + application.fileBackups?.isPlaintextBackupsEnabled() || + application.fileBackups?.isTextBackupsEnabled() const cancelRef = useRef(null) const closeDialog = useCallback(() => { viewControllerManager.accountMenuController.setSigningOut(false) }, [viewControllerManager.accountMenuController]) - const [localBackupsCount, setLocalBackupsCount] = useState(0) - - useEffect(() => { - application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error) - }, [viewControllerManager.accountMenuController.signingOut, application.desktopDevice]) - const workspaces = applicationGroup.getDescriptors() const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication() const confirm = useCallback(() => { - if (deleteLocalBackups) { - application.signOutAndDeleteLocalBackups().catch(console.error) - } else { - application.user.signOut().catch(console.error) - } + application.user.signOut().catch(console.error) + closeDialog() - }, [application, closeDialog, deleteLocalBackups]) + }, [application, closeDialog]) return ( @@ -66,31 +61,26 @@ const ConfirmSignoutModal: FunctionComponent = ({ application, viewContro - {localBackupsCount > 0 && ( -
-
- - -
+ {hasAnyBackupsEnabled && ( + <> + +
+
+
+

+ Local backups are enabled for this workspace. Review your backup files manually to decide what to keep. +

+ +
+
+ )}
diff --git a/packages/web/src/javascripts/Components/FileContextMenu/FileContextMenuBackupOption.tsx b/packages/web/src/javascripts/Components/FileContextMenu/FileContextMenuBackupOption.tsx index e59af1f13..85f9fbab8 100644 --- a/packages/web/src/javascripts/Components/FileContextMenu/FileContextMenuBackupOption.tsx +++ b/packages/web/src/javascripts/Components/FileContextMenu/FileContextMenuBackupOption.tsx @@ -35,7 +35,7 @@ export const FileContextMenuBackupOption: FunctionComponent<{ file: FileItem }> >
Backed up on {dateToStringStyle1(backupInfo.backedUpOn)}
-
{backupInfo.absolutePath}
+
{application.fileBackups?.getFileBackupAbsolutePath(backupInfo)}
)} diff --git a/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx b/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx index be07739d4..dcd004b72 100644 --- a/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx +++ b/packages/web/src/javascripts/Components/PasswordWizard/PasswordWizard.tsx @@ -153,7 +153,7 @@ class PasswordWizard extends AbstractComponent { } async processPasswordChange() { - await this.application.downloadBackup() + await this.application.performDesktopTextBackup() this.setState({ lockContinue: true, diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/ChangeEmail/ChangeEmail.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/ChangeEmail/ChangeEmail.tsx index 8b8266fbb..f857c0c07 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/ChangeEmail/ChangeEmail.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/ChangeEmail/ChangeEmail.tsx @@ -58,7 +58,7 @@ const ChangeEmail: FunctionComponent = ({ onCloseDialog, application }) = } const processEmailChange = useCallback(async () => { - await application.downloadBackup() + await application.performDesktopTextBackup() setLockContinue(true) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Backups.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Backups.tsx index b8daf70fc..22b8dcab0 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Backups.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Backups.tsx @@ -6,6 +6,8 @@ import DataBackups from './DataBackups' import EmailBackups from './EmailBackups' import FileBackupsCrossPlatform from './Files/FileBackupsCrossPlatform' import { observer } from 'mobx-react-lite' +import TextBackupsCrossPlatform from './TextBackups/TextBackupsCrossPlatform' +import PlaintextBackupsCrossPlatform from './PlaintextBackups/PlaintextBackupsCrossPlatform' type Props = { viewControllerManager: ViewControllerManager @@ -16,6 +18,8 @@ const Backups: FunctionComponent = ({ application, viewControllerManager return ( + + diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx index fea368882..0dd3088f3 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx @@ -1,4 +1,3 @@ -import { isDesktopApplication } from '@/Utils' import { alertDialog, sanitizeFileName } from '@standardnotes/ui-services' import { STRING_IMPORT_SUCCESS, @@ -15,7 +14,7 @@ import { ChangeEventHandler, MouseEventHandler, useCallback, useEffect, useRef, import { WebApplication } from '@/Application/Application' import { ViewControllerManager } from '@/Controllers/ViewControllerManager' import { observer } from 'mobx-react-lite' -import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' +import { Title, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' import Button from '@/Components/Button/Button' import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' @@ -177,14 +176,7 @@ const DataBackups = ({ application, viewControllerManager }: Props) => { Data Backups - - {isDesktopApplication() && ( - - Backups are automatically created on desktop and can be managed via the "Backups" top-level menu. - - )} - - Download a backup of all your data + Download a backup of all your text-based data {isEncryptionEnabled && (
diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/EmailBackups.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/EmailBackups.tsx index fc4260f27..c1d939e22 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/EmailBackups.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/EmailBackups.tsx @@ -118,7 +118,7 @@ const EmailBackups = ({ application }: Props) => { )}
- Email frequency + Frequency How often to receive backups.
{isLoading ? ( diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsCrossPlatform.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsCrossPlatform.tsx index ec5fa4256..074ea2fdf 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsCrossPlatform.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsCrossPlatform.tsx @@ -15,13 +15,13 @@ const FileBackupsCrossPlatform = ({ application }: Props) => { const fileBackupsService = useMemo(() => application.fileBackups, [application]) return fileBackupsService ? ( - + ) : ( <> - File Backups - Automatically save encrypted backups of files uploaded on any device to this computer. + Automatic File Backups + Automatically save encrypted backups of your files. To enable file backups, use the Standard Notes desktop application. diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsDesktop.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsDesktop.tsx index bdfaede5f..74c4ee0cf 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsDesktop.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/Files/FileBackupsDesktop.tsx @@ -1,7 +1,6 @@ -import { WebApplication } from '@/Application/Application' import { observer } from 'mobx-react-lite' import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import Button from '@/Components/Button/Button' import Switch from '@/Components/Switch/Switch' import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' @@ -10,30 +9,21 @@ import BackupsDropZone from './BackupsDropZone' import EncryptionStatusItem from '../../Security/EncryptionStatusItem' import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import { BackupServiceInterface } from '@standardnotes/snjs' +import { useApplication } from '@/Components/ApplicationProvider' type Props = { - application: WebApplication - backupsService: NonNullable + backupsService: BackupServiceInterface } -const FileBackupsDesktop = ({ application, backupsService }: Props) => { - const [backupsEnabled, setBackupsEnabled] = useState(false) - const [backupsLocation, setBackupsLocation] = useState('') - - useEffect(() => { - void backupsService.isFilesBackupsEnabled().then(setBackupsEnabled) - }, [backupsService]) - - useEffect(() => { - if (backupsEnabled) { - void backupsService.getFilesBackupsLocation().then(setBackupsLocation) - } - }, [backupsService, backupsEnabled]) +const FileBackupsDesktop = ({ backupsService }: Props) => { + const application = useApplication() + const [backupsEnabled, setBackupsEnabled] = useState(backupsService.isFilesBackupsEnabled()) + const [backupsLocation, setBackupsLocation] = useState(backupsService.getFilesBackupsLocation()) const changeBackupsLocation = useCallback(async () => { - await backupsService.changeFilesBackupsLocation() - - setBackupsLocation(await backupsService.getFilesBackupsLocation()) + const newLocation = await backupsService.changeFilesBackupsLocation() + setBackupsLocation(newLocation) }, [backupsService]) const openBackupsLocation = useCallback(async () => { @@ -42,25 +32,24 @@ const FileBackupsDesktop = ({ application, backupsService }: Props) => { const toggleBackups = useCallback(async () => { if (backupsEnabled) { - await backupsService.disableFilesBackups() + backupsService.disableFilesBackups() } else { await backupsService.enableFilesBackups() } - setBackupsEnabled(await backupsService.isFilesBackupsEnabled()) + setBackupsEnabled(backupsService.isFilesBackupsEnabled()) + setBackupsLocation(backupsService.getFilesBackupsLocation()) }, [backupsService, backupsEnabled]) return ( <> - File Backups + Automatic File Backups
- - Automatically save encrypted backups of files uploaded on any device to this computer. - + Automatically save encrypted backups of your uploaded files to this computer.
@@ -85,14 +74,14 @@ const FileBackupsDesktop = ({ application, backupsService }: Props) => { } checkmark={false} />
-
diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/PlaintextBackups/PlaintextBackupsCrossPlatform.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/PlaintextBackups/PlaintextBackupsCrossPlatform.tsx new file mode 100644 index 000000000..9bd46767e --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/PlaintextBackups/PlaintextBackupsCrossPlatform.tsx @@ -0,0 +1,28 @@ +import { Subtitle, Title, Text } from '@/Components/Preferences/PreferencesComponents/Content' +import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' + +import { useMemo } from 'react' +import PlaintextBackupsDesktop from './PlaintextBackupsDesktop' +import { useApplication } from '@/Components/ApplicationProvider' + +const PlaintextBackupsCrossPlatform = () => { + const application = useApplication() + const fileBackupsService = useMemo(() => application.fileBackups, [application]) + + return fileBackupsService ? ( + + ) : ( + <> + + + Automatic Plaintext Backups + Automatically save backups of all your notes into plaintext, non-encrypted folders. + To enable plaintext backups, use the Standard Notes desktop application. + + + + ) +} + +export default PlaintextBackupsCrossPlatform diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/PlaintextBackups/PlaintextBackupsDesktop.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/PlaintextBackups/PlaintextBackupsDesktop.tsx new file mode 100644 index 000000000..c8fc35184 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/PlaintextBackups/PlaintextBackupsDesktop.tsx @@ -0,0 +1,89 @@ +import { observer } from 'mobx-react-lite' +import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' +import { useCallback, useState } from 'react' +import Button from '@/Components/Button/Button' +import Switch from '@/Components/Switch/Switch' +import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' +import Icon from '@/Components/Icon/Icon' +import EncryptionStatusItem from '../../Security/EncryptionStatusItem' +import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import { BackupServiceInterface } from '@standardnotes/snjs' + +type Props = { + backupsService: BackupServiceInterface +} + +const PlaintextBackupsDesktop = ({ backupsService }: Props) => { + const [backupsEnabled, setBackupsEnabled] = useState(backupsService.isPlaintextBackupsEnabled()) + const [backupsLocation, setBackupsLocation] = useState(backupsService.getPlaintextBackupsLocation()) + + const changeBackupsLocation = useCallback(async () => { + const newLocation = await backupsService.changePlaintextBackupsLocation() + setBackupsLocation(newLocation) + }, [backupsService]) + + const openBackupsLocation = useCallback(async () => { + await backupsService.openPlaintextBackupsLocation() + }, [backupsService]) + + const toggleBackups = useCallback(async () => { + if (backupsEnabled) { + backupsService.disablePlaintextBackups() + } else { + await backupsService.enablePlaintextBackups() + } + + setBackupsEnabled(backupsService.isPlaintextBackupsEnabled()) + setBackupsLocation(backupsService.getPlaintextBackupsLocation()) + }, [backupsEnabled, backupsService]) + + return ( + <> + + + Automatic Plaintext Backups + +
+
+ + Automatically save backups of all your notes to this computer into plaintext, non-encrypted folders. + +
+ +
+ + {!backupsEnabled && ( + <> + + Plaintext backups are not enabled. Enable to choose where your data is backed up. + + )} +
+ + {backupsEnabled && ( + <> + + + <> + Plaintext backups are enabled and saved to: + } + checkmark={false} + /> + +
+
+ +
+ + )} +
+ + ) +} + +export default observer(PlaintextBackupsDesktop) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/TextBackups/TextBackupsCrossPlatform.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/TextBackups/TextBackupsCrossPlatform.tsx new file mode 100644 index 000000000..41184f919 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/TextBackups/TextBackupsCrossPlatform.tsx @@ -0,0 +1,30 @@ +import { Subtitle, Title, Text } from '@/Components/Preferences/PreferencesComponents/Content' +import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import { WebApplication } from '@/Application/Application' +import { useMemo } from 'react' +import TextBackupsDesktop from './TextBackupsDesktop' + +type Props = { + application: WebApplication +} + +const TextBackupsCrossPlatform = ({ application }: Props) => { + const fileBackupsService = useMemo(() => application.fileBackups, [application]) + + return fileBackupsService ? ( + + ) : ( + <> + + + Automatic Text Backups + Automatically save encrypted and decrypted backups of your note and tag data. + To enable text backups, use the Standard Notes desktop application. + + + + ) +} + +export default TextBackupsCrossPlatform diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/TextBackups/TextBackupsDesktop.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/TextBackups/TextBackupsDesktop.tsx new file mode 100644 index 000000000..7dfd4a371 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/TextBackups/TextBackupsDesktop.tsx @@ -0,0 +1,106 @@ +import { observer } from 'mobx-react-lite' +import { Title, Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content' +import { useCallback, useState } from 'react' +import Button from '@/Components/Button/Button' +import Switch from '@/Components/Switch/Switch' +import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' +import Icon from '@/Components/Icon/Icon' +import EncryptionStatusItem from '../../Security/EncryptionStatusItem' +import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment' +import { BackupServiceInterface } from '@standardnotes/snjs' +import { useApplication } from '@/Components/ApplicationProvider' + +type Props = { + backupsService: BackupServiceInterface +} + +const TextBackupsDesktop = ({ backupsService }: Props) => { + const application = useApplication() + const [backupsEnabled, setBackupsEnabled] = useState(backupsService.isTextBackupsEnabled()) + const [backupsLocation, setBackupsLocation] = useState(backupsService.getTextBackupsLocation()) + + const changeBackupsLocation = useCallback(async () => { + const newLocation = await backupsService.changeTextBackupsLocation() + setBackupsLocation(newLocation) + }, [backupsService]) + + const openBackupsLocation = useCallback(async () => { + await backupsService.openTextBackupsLocation() + }, [backupsService]) + + const toggleBackups = useCallback(async () => { + if (backupsEnabled) { + backupsService.disableTextBackups() + } else { + await backupsService.enableTextBackups() + } + + setBackupsEnabled(backupsService.isTextBackupsEnabled()) + setBackupsLocation(backupsService.getTextBackupsLocation()) + }, [backupsEnabled, backupsService]) + + const performBackup = useCallback(async () => { + void application.getDesktopService()?.saveDesktopBackup() + }, [application]) + + return ( + <> + + + Automatic Encrypted Text Backups + +
+
+ + Automatically save encrypted text backups of all your note and tag data to this computer. + +
+ +
+ + {!backupsEnabled && ( + <> + + Text backups are not enabled. Enable to choose where your data is backed up. + + )} +
+ + {backupsEnabled && ( + <> + + + + <> + Text backups are enabled and saved to: + + } + checkmark={false} + /> + +
+
+ + + + + + Backups are saved automatically throughout the day. You can perform a one-time backup now below. + +
+
+
+ + )} +
+ + ) +} + +export default observer(TextBackupsDesktop) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorTitle.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorTitle.tsx index 464a22f45..90c069d07 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorTitle.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Security/TwoFactorAuth/TwoFactorAuthView/TwoFactorTitle.tsx @@ -12,7 +12,7 @@ const TwoFactorTitle: FunctionComponent = ({ auth }) => { return Two-factor authentication not available } - return Two-factor authentication + return Two-Factor Authentication } export default observer(TwoFactorTitle) diff --git a/packages/web/src/javascripts/Controllers/Abstract/AbstractViewController.ts b/packages/web/src/javascripts/Controllers/Abstract/AbstractViewController.ts index 7344ecc1c..ed2fd8021 100644 --- a/packages/web/src/javascripts/Controllers/Abstract/AbstractViewController.ts +++ b/packages/web/src/javascripts/Controllers/Abstract/AbstractViewController.ts @@ -1,5 +1,5 @@ import { CrossControllerEvent } from '../CrossControllerEvent' -import { InternalEventBus, InternalEventPublishStrategy, removeFromArray } from '@standardnotes/snjs' +import { InternalEventBusInterface, InternalEventPublishStrategy, removeFromArray } from '@standardnotes/snjs' import { WebApplication } from '../../Application/Application' import { Disposer } from '@/Types/Disposer' @@ -10,7 +10,7 @@ export abstract class AbstractViewController { protected disposers: Disposer[] = [] private eventObservers: ControllerEventObserver[] = [] - constructor(public application: WebApplication, protected eventBus: InternalEventBus) {} + constructor(public application: WebApplication, protected eventBus: InternalEventBusInterface) {} protected async publishCrossControllerEventSync(name: CrossControllerEvent, data?: unknown): Promise { await this.eventBus.publishSync({ type: name, payload: data }, InternalEventPublishStrategy.SEQUENCE) diff --git a/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts b/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts index 12db2ed3b..26c870b81 100644 --- a/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts +++ b/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts @@ -1,5 +1,5 @@ import { addToast, dismissToast, ToastType } from '@standardnotes/toast' -import { ApplicationEvent, InternalEventBus, StorageKey } from '@standardnotes/services' +import { ApplicationEvent, InternalEventBusInterface, StorageKey } from '@standardnotes/services' import { isDev } from '@/Utils' import { FileItem, PrefKey, sleep, SNTag } from '@standardnotes/snjs' import { FilesController } from '../FilesController' @@ -19,7 +19,11 @@ export class MomentsService extends AbstractViewController { isEnabled = false private intervalReference: ReturnType | undefined - constructor(application: WebApplication, private filesController: FilesController, eventBus: InternalEventBus) { + constructor( + application: WebApplication, + private filesController: FilesController, + eventBus: InternalEventBusInterface, + ) { super(application, eventBus) this.disposers.push( diff --git a/packages/web/src/javascripts/Controllers/PreferencesController.ts b/packages/web/src/javascripts/Controllers/PreferencesController.ts index 4e0c823ac..068b67108 100644 --- a/packages/web/src/javascripts/Controllers/PreferencesController.ts +++ b/packages/web/src/javascripts/Controllers/PreferencesController.ts @@ -4,10 +4,10 @@ import { PreferenceId, RootQueryParam } from '@standardnotes/ui-services' import { AbstractViewController } from './Abstract/AbstractViewController' import { WebApplication } from '@/Application/Application' -const DEFAULT_PANE: PreferenceId = 'account' +const DEFAULT_PANE: PreferenceId = 'backups' export class PreferencesController extends AbstractViewController { - private _open = false + private _open = true currentPane: PreferenceId = DEFAULT_PANE constructor(application: WebApplication, eventBus: InternalEventBus) {