feat: New one-click Home Server, now in Labs. Launch your own self-hosted server instance with just 1 click from the Preferences window. (#2341)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import path from 'path'
|
||||
import { shell } from 'electron'
|
||||
import { DirectoryManagerInterface } from '@standardnotes/snjs'
|
||||
|
||||
import { FilesManagerInterface } from '../File/FilesManagerInterface'
|
||||
|
||||
export class DirectoryManager implements DirectoryManagerInterface {
|
||||
private lastErrorMessage: string | undefined
|
||||
|
||||
constructor(private filesManager: FilesManagerInterface) {}
|
||||
|
||||
async presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
appendPath: string,
|
||||
oldLocation?: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
this.lastErrorMessage = undefined
|
||||
|
||||
const selectedDirectory = await this.filesManager.openDirectoryPicker('Select')
|
||||
|
||||
if (!selectedDirectory) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const newPath = path.join(selectedDirectory, path.normalize(appendPath))
|
||||
|
||||
await this.filesManager.ensureDirectoryExists(newPath)
|
||||
|
||||
if (oldLocation) {
|
||||
const result = await this.filesManager.moveDirectory(path.normalize(oldLocation), newPath)
|
||||
if (result.isFailed()) {
|
||||
this.lastErrorMessage = result.getError()
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const deletingDirectoryResult = await this.filesManager.deleteDir(path.normalize(oldLocation))
|
||||
if (deletingDirectoryResult.isFailed()) {
|
||||
this.lastErrorMessage = deletingDirectoryResult.getError()
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return newPath
|
||||
} catch (error) {
|
||||
this.lastErrorMessage = (error as Error).message
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async openLocation(location: string): Promise<void> {
|
||||
void shell.openPath(location)
|
||||
}
|
||||
|
||||
async getDirectoryManagerLastErrorMessage(): Promise<string | undefined> {
|
||||
return this.lastErrorMessage
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { extensions as str } from './Strings'
|
||||
import { Paths } from './Types/Paths'
|
||||
import { FileDoesNotExist } from './Utils/FileUtils'
|
||||
import { app } from 'electron'
|
||||
import { FileErrorCodes } from './File/FileErrorCodes'
|
||||
|
||||
const Protocol = 'http'
|
||||
|
||||
@@ -80,7 +80,7 @@ function onRequestError(error: Error | { code: string }, response: ServerRespons
|
||||
let responseCode: number
|
||||
let message: string
|
||||
|
||||
if ('code' in error && error.code === FileDoesNotExist) {
|
||||
if ('code' in error && error.code === FileErrorCodes.FileDoesNotExist) {
|
||||
responseCode = 404
|
||||
message = str().missingExtension
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum FileErrorCodes {
|
||||
FileDoesNotExist = 'ENOENT',
|
||||
FileAlreadyExists = 'EEXIST',
|
||||
OperationNotPermitted = 'EPERM',
|
||||
DeviceIsBusy = 'EBUSY',
|
||||
}
|
||||
314
packages/desktop/app/javascripts/Main/File/FilesManager.ts
Normal file
314
packages/desktop/app/javascripts/Main/File/FilesManager.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { dialog } from 'electron'
|
||||
import fs, { PathLike } from 'fs'
|
||||
import fse from 'fs-extra'
|
||||
import { debounce } from 'lodash'
|
||||
import path from 'path'
|
||||
import yauzl from 'yauzl'
|
||||
import { Result } from '@standardnotes/domain-core'
|
||||
|
||||
import { removeFromArray } from '../Utils/Utils'
|
||||
|
||||
import { FileErrorCodes } from './FileErrorCodes'
|
||||
import { FilesManagerInterface } from './FilesManagerInterface'
|
||||
|
||||
export class FilesManager implements FilesManagerInterface {
|
||||
debouncedJSONDiskWriter(durationMs: number, location: string, data: () => unknown): () => void {
|
||||
let writingToDisk = false
|
||||
return debounce(async () => {
|
||||
if (writingToDisk) {
|
||||
return
|
||||
}
|
||||
writingToDisk = true
|
||||
try {
|
||||
await this.writeJSONFile(location, data())
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
writingToDisk = false
|
||||
}
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
async openDirectoryPicker(buttonLabel?: string): Promise<string | undefined> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'],
|
||||
buttonLabel: buttonLabel,
|
||||
})
|
||||
|
||||
return result.filePaths[0]
|
||||
}
|
||||
|
||||
async readJSONFile<T>(filepath: string): Promise<T | undefined> {
|
||||
try {
|
||||
const data = await fs.promises.readFile(filepath, 'utf8')
|
||||
return JSON.parse(data)
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
readJSONFileSync<T>(filepath: string): T {
|
||||
const data = fs.readFileSync(filepath, 'utf8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
|
||||
async writeJSONFile(filepath: string, data: unknown): Promise<void> {
|
||||
await this.ensureDirectoryExists(path.dirname(filepath))
|
||||
await fs.promises.writeFile(filepath, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
async writeFile(filepath: string, data: string): Promise<void> {
|
||||
await this.ensureDirectoryExists(path.dirname(filepath))
|
||||
await fs.promises.writeFile(filepath, data, 'utf8')
|
||||
}
|
||||
|
||||
writeJSONFileSync(filepath: string, data: unknown): void {
|
||||
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
async ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.promises.lstat(dirPath)
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('Tried to create a directory where a file of the same ' + `name already exists: ${dirPath}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === FileErrorCodes.FileDoesNotExist) {
|
||||
/**
|
||||
* No directory here. Make sure there is a *parent* directory, and then
|
||||
* create it.
|
||||
*/
|
||||
await this.ensureDirectoryExists(path.dirname(dirPath))
|
||||
|
||||
/** Now that its parent(s) exist, create the directory */
|
||||
try {
|
||||
await fs.promises.mkdir(dirPath)
|
||||
} catch (error: any) {
|
||||
if (error.code === FileErrorCodes.FileAlreadyExists) {
|
||||
/**
|
||||
* A concurrent process must have created the directory already.
|
||||
* Make sure it *is* a directory and not something else.
|
||||
*/
|
||||
await this.ensureDirectoryExists(dirPath)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDir(dirPath: string): Promise<Result<string>> {
|
||||
try {
|
||||
fse.removeSync(dirPath)
|
||||
|
||||
return Result.ok('Directory deleted successfully')
|
||||
} catch (error) {
|
||||
return Result.fail((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDirContents(dirPath: string): Promise<void> {
|
||||
/**
|
||||
* Scan the directory up to ten times, to handle cases where files are being added while
|
||||
* the directory's contents are being deleted
|
||||
*/
|
||||
for (let i = 1, maxTries = 10; i < maxTries; i++) {
|
||||
const children = await fs.promises.readdir(dirPath, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
|
||||
if (children.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
const childPath = path.join(dirPath, child.name)
|
||||
if (child.isDirectory()) {
|
||||
await this.deleteDirContents(childPath)
|
||||
try {
|
||||
await fs.promises.rmdir(childPath)
|
||||
} catch (error) {
|
||||
if (error !== FileErrorCodes.FileDoesNotExist) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.deleteFile(childPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isChildOfDir(parent: string, potentialChild: string): boolean {
|
||||
const relative = path.relative(parent, potentialChild)
|
||||
return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
async moveDirectory(dir: string, destination: string): Promise<Result<string>> {
|
||||
try {
|
||||
await fse.move(dir, destination, { overwrite: true })
|
||||
|
||||
return Result.ok('Directory moved successfully')
|
||||
} catch (error) {
|
||||
return Result.fail((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async moveDirContents(srcDir: string, destDir: string): Promise<Result<string>> {
|
||||
try {
|
||||
let srcDirectoryContents = await fs.promises.readdir(srcDir)
|
||||
|
||||
await this.ensureDirectoryExists(destDir)
|
||||
|
||||
if (this.isChildOfDir(srcDir, destDir)) {
|
||||
srcDirectoryContents = srcDirectoryContents.filter((name) => {
|
||||
return !this.isChildOfDir(destDir, path.join(srcDir, name))
|
||||
})
|
||||
removeFromArray(srcDirectoryContents, path.basename(destDir))
|
||||
}
|
||||
|
||||
const directoryNames = []
|
||||
const fileNames = []
|
||||
for (const contentName of srcDirectoryContents) {
|
||||
const stats = await fs.promises.lstat(path.join(srcDir, contentName))
|
||||
if (stats.isDirectory()) {
|
||||
directoryNames.push(contentName)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
fileNames.push(contentName)
|
||||
}
|
||||
|
||||
for (const directoryName of directoryNames) {
|
||||
const result = await this.moveDirContents(path.join(srcDir, directoryName), path.join(destDir, directoryName))
|
||||
if (result.isFailed()) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
await this.moveFiles(
|
||||
fileNames.map((fileName) => path.join(srcDir, fileName)),
|
||||
destDir,
|
||||
)
|
||||
|
||||
return Result.ok('Directory contents moved successfully')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
return Result.fail(`Could not move directory contentes: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async extractZip(source: string, dest: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(source, { lazyEntries: true, autoClose: true }, (err, zipFile) => {
|
||||
let cancelled = false
|
||||
|
||||
const tryReject = (err: Error) => {
|
||||
if (!cancelled) {
|
||||
cancelled = true
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return tryReject(err)
|
||||
}
|
||||
|
||||
if (!zipFile) {
|
||||
return tryReject(new Error('zipFile === undefined'))
|
||||
}
|
||||
|
||||
zipFile.readEntry()
|
||||
|
||||
zipFile.on('close', resolve)
|
||||
|
||||
zipFile.on('entry', (entry) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const isEntryDirectory = entry.fileName.endsWith('/')
|
||||
if (isEntryDirectory) {
|
||||
zipFile.readEntry()
|
||||
return
|
||||
}
|
||||
|
||||
zipFile.openReadStream(entry, async (err, stream) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return tryReject(err)
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
return tryReject(new Error('stream === undefined'))
|
||||
}
|
||||
|
||||
stream.on('error', tryReject)
|
||||
|
||||
const filepath = path.join(dest, entry.fileName)
|
||||
|
||||
try {
|
||||
await this.ensureDirectoryExists(path.dirname(filepath))
|
||||
} catch (error: any) {
|
||||
return tryReject(error)
|
||||
}
|
||||
const writeStream = fs.createWriteStream(filepath).on('error', tryReject).on('error', tryReject)
|
||||
|
||||
stream.pipe(writeStream).on('close', () => {
|
||||
zipFile.readEntry()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async moveFiles(sources: string[], destDir: string): Promise<void[]> {
|
||||
await this.ensureDirectoryExists(destDir)
|
||||
return Promise.all(sources.map((fileName) => this.moveFile(fileName, path.join(destDir, path.basename(fileName)))))
|
||||
}
|
||||
|
||||
async moveFile(source: PathLike, destination: PathLike): Promise<void> {
|
||||
try {
|
||||
await fs.promises.rename(source, destination)
|
||||
} catch (_error) {
|
||||
/** Fall back to copying and then deleting. */
|
||||
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_FICLONE_FORCE)
|
||||
await fs.promises.unlink(source)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFileIfExists(filePath: PathLike): Promise<void> {
|
||||
try {
|
||||
await this.deleteFile(filePath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(filePath: PathLike): Promise<void> {
|
||||
for (let i = 1, maxTries = 10; i < maxTries; i++) {
|
||||
try {
|
||||
await fs.promises.unlink(filePath)
|
||||
break
|
||||
} catch (error: any) {
|
||||
if (error.code === FileErrorCodes.OperationNotPermitted || error.code === FileErrorCodes.DeviceIsBusy) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
continue
|
||||
} else if (error.code === FileErrorCodes.FileDoesNotExist) {
|
||||
/** Already deleted */
|
||||
break
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Result } from '@standardnotes/domain-core'
|
||||
import { PathLike } from 'fs'
|
||||
|
||||
export interface FilesManagerInterface {
|
||||
debouncedJSONDiskWriter(durationMs: number, location: string, data: () => unknown): () => void
|
||||
openDirectoryPicker(buttonLabel?: string): Promise<string | undefined>
|
||||
readJSONFile<T>(filepath: string): Promise<T | undefined>
|
||||
readJSONFileSync<T>(filepath: string): T
|
||||
writeJSONFile(filepath: string, data: unknown): Promise<void>
|
||||
writeFile(filepath: string, data: string): Promise<void>
|
||||
writeJSONFileSync(filepath: string, data: unknown): void
|
||||
ensureDirectoryExists(dirPath: string): Promise<void>
|
||||
deleteDir(dirPath: string): Promise<Result<string>>
|
||||
deleteDirContents(dirPath: string): Promise<void>
|
||||
isChildOfDir(parent: string, potentialChild: string): boolean
|
||||
moveDirectory(dir: string, destination: string): Promise<Result<string>>
|
||||
moveDirContents(srcDir: string, destDir: string): Promise<Result<string>>
|
||||
extractZip(source: string, dest: string): Promise<void>
|
||||
moveFiles(sources: string[], destDir: string): Promise<void[]>
|
||||
moveFile(source: PathLike, destination: PathLike): Promise<void>
|
||||
deleteFileIfExists(filePath: PathLike): Promise<void>
|
||||
deleteFile(filePath: PathLike): Promise<void>
|
||||
}
|
||||
@@ -9,23 +9,14 @@ import {
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { AppState } from 'app/AppState'
|
||||
import { promises as fs, existsSync } from 'fs'
|
||||
import { WebContents, shell } from 'electron'
|
||||
import { WebContents } from 'electron'
|
||||
import { StoreKeys } from '../Store/StoreKeys'
|
||||
import path from 'path'
|
||||
import {
|
||||
deleteFileIfExists,
|
||||
ensureDirectoryExists,
|
||||
moveDirContents,
|
||||
moveFile,
|
||||
openDirectoryPicker,
|
||||
readJSONFile,
|
||||
writeFile,
|
||||
writeJSONFile,
|
||||
} from '../Utils/FileUtils'
|
||||
import { FileDownloader } from './FileDownloader'
|
||||
import { FileReadOperation } from './FileReadOperation'
|
||||
import { Paths } from '../Types/Paths'
|
||||
import { MessageToWebApp } from '../../Shared/IpcMessages'
|
||||
import { FilesManagerInterface } from '../File/FilesManagerInterface'
|
||||
|
||||
const TextBackupFileExtension = '.txt'
|
||||
|
||||
@@ -39,7 +30,11 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
private readOperations: Map<string, FileReadOperation> = new Map()
|
||||
private plaintextMappingCache?: PlaintextBackupsMapping
|
||||
|
||||
constructor(private appState: AppState, private webContents: WebContents) {}
|
||||
constructor(
|
||||
private appState: AppState,
|
||||
private webContents: WebContents,
|
||||
private filesManager: FilesManagerInterface,
|
||||
) {}
|
||||
|
||||
private async findUuidForPlaintextBackupFileName(
|
||||
backupsDirectory: string,
|
||||
@@ -72,16 +67,16 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureDirectoryExists(newLocation)
|
||||
await this.filesManager.ensureDirectoryExists(newLocation)
|
||||
|
||||
const legacyMappingLocation = path.join(legacyLocation, 'info.json')
|
||||
const newMappingLocation = this.getFileBackupsMappingFilePath(newLocation)
|
||||
await ensureDirectoryExists(path.dirname(newMappingLocation))
|
||||
await this.filesManager.ensureDirectoryExists(path.dirname(newMappingLocation))
|
||||
if (existsSync(legacyMappingLocation)) {
|
||||
await moveFile(legacyMappingLocation, newMappingLocation)
|
||||
await this.filesManager.moveFile(legacyMappingLocation, newMappingLocation)
|
||||
}
|
||||
|
||||
await moveDirContents(legacyLocation, newLocation)
|
||||
await this.filesManager.moveDirContents(legacyLocation, newLocation)
|
||||
}
|
||||
|
||||
public async isLegacyFilesBackupsEnabled(): Promise<boolean> {
|
||||
@@ -111,33 +106,12 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
return path.join(Paths.homeDir, LegacyTextBackupsDirectory)
|
||||
}
|
||||
|
||||
public async presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
appendPath: string,
|
||||
oldLocation?: string,
|
||||
): Promise<string | undefined> {
|
||||
const selectedDirectory = await openDirectoryPicker('Select')
|
||||
|
||||
if (!selectedDirectory) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const newPath = path.join(selectedDirectory, path.normalize(appendPath))
|
||||
|
||||
await ensureDirectoryExists(newPath)
|
||||
|
||||
if (oldLocation) {
|
||||
await moveDirContents(path.normalize(oldLocation), newPath)
|
||||
}
|
||||
|
||||
return newPath
|
||||
}
|
||||
|
||||
private getFileBackupsMappingFilePath(backupsLocation: string): string {
|
||||
return path.join(backupsLocation, '.settings', 'info.json')
|
||||
}
|
||||
|
||||
private async getFileBackupsMappingFileFromDisk(backupsLocation: string): Promise<FileBackupsMapping | undefined> {
|
||||
return readJSONFile<FileBackupsMapping>(this.getFileBackupsMappingFilePath(backupsLocation))
|
||||
return this.filesManager.readJSONFile<FileBackupsMapping>(this.getFileBackupsMappingFilePath(backupsLocation))
|
||||
}
|
||||
|
||||
private defaulFileBackupstMappingFileValue(): FileBackupsMapping {
|
||||
@@ -158,12 +132,8 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
return data
|
||||
}
|
||||
|
||||
async openLocation(location: string): Promise<void> {
|
||||
void shell.openPath(location)
|
||||
}
|
||||
|
||||
private async saveFilesBackupsMappingFile(location: string, file: FileBackupsMapping): Promise<'success' | 'failed'> {
|
||||
await writeJSONFile(this.getFileBackupsMappingFilePath(location), file)
|
||||
await this.filesManager.writeJSONFile(this.getFileBackupsMappingFilePath(location), file)
|
||||
|
||||
return 'success'
|
||||
}
|
||||
@@ -182,9 +152,9 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
const metaFilePath = path.join(fileDir, FileBackupsConstantsV1.MetadataFileName)
|
||||
const binaryPath = path.join(fileDir, FileBackupsConstantsV1.BinaryFileName)
|
||||
|
||||
await ensureDirectoryExists(fileDir)
|
||||
await this.filesManager.ensureDirectoryExists(fileDir)
|
||||
|
||||
await writeFile(metaFilePath, metaFile)
|
||||
await this.filesManager.writeFile(metaFilePath, metaFile)
|
||||
|
||||
const downloader = new FileDownloader(
|
||||
downloadRequest.chunkSizes,
|
||||
@@ -247,7 +217,7 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
let success: boolean
|
||||
|
||||
try {
|
||||
await ensureDirectoryExists(location)
|
||||
await this.filesManager.ensureDirectoryExists(location)
|
||||
const name = `${new Date().toISOString().replace(/:/g, '-')}${TextBackupFileExtension}`
|
||||
const filePath = path.join(location, name)
|
||||
await fs.writeFile(filePath, data)
|
||||
@@ -262,7 +232,7 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
|
||||
async copyDecryptScript(location: string) {
|
||||
try {
|
||||
await ensureDirectoryExists(location)
|
||||
await this.filesManager.ensureDirectoryExists(location)
|
||||
await fs.copyFile(Paths.decryptScript, path.join(location, path.basename(Paths.decryptScript)))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -274,14 +244,14 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
}
|
||||
|
||||
private async getPlaintextMappingFileFromDisk(location: string): Promise<PlaintextBackupsMapping | undefined> {
|
||||
return readJSONFile<PlaintextBackupsMapping>(this.getPlaintextMappingFilePath(location))
|
||||
return this.filesManager.readJSONFile<PlaintextBackupsMapping>(this.getPlaintextMappingFilePath(location))
|
||||
}
|
||||
|
||||
private async savePlaintextBackupsMappingFile(
|
||||
location: string,
|
||||
file: PlaintextBackupsMapping,
|
||||
): Promise<'success' | 'failed'> {
|
||||
await writeJSONFile(this.getPlaintextMappingFilePath(location), file)
|
||||
await this.filesManager.writeJSONFile(this.getPlaintextMappingFilePath(location), file)
|
||||
|
||||
return 'success'
|
||||
}
|
||||
@@ -324,7 +294,7 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
const records = mapping.files[uuid]
|
||||
for (const record of records) {
|
||||
const filePath = path.join(location, record.path)
|
||||
await deleteFileIfExists(filePath)
|
||||
await this.filesManager.deleteFileIfExists(filePath)
|
||||
}
|
||||
mapping.files[uuid] = []
|
||||
}
|
||||
@@ -337,12 +307,12 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
return records.find((record) => record.tag === tag)
|
||||
}
|
||||
|
||||
await ensureDirectoryExists(absolutePath)
|
||||
await this.filesManager.ensureDirectoryExists(absolutePath)
|
||||
|
||||
const relativePath = forTag ?? ''
|
||||
const filenameWithSlashesEscaped = filename.replace(/\//g, '\u2215')
|
||||
const fileAbsolutePath = path.join(absolutePath, relativePath, filenameWithSlashesEscaped)
|
||||
await writeFile(fileAbsolutePath, data)
|
||||
await this.filesManager.writeFile(fileAbsolutePath, data)
|
||||
|
||||
const existingRecord = findMappingRecord(forTag)
|
||||
if (!existingRecord) {
|
||||
@@ -385,6 +355,10 @@ export class FilesBackupManager implements FileBackupsDevice {
|
||||
const watcher = fs.watch(backupsDirectory, { recursive: true })
|
||||
for await (const event of watcher) {
|
||||
const { eventType, filename } = event
|
||||
if (!filename) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (eventType !== 'change' && eventType !== 'rename') {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { HomeServerEnvironmentConfiguration } from '@standardnotes/snjs'
|
||||
|
||||
export interface HomeServerConfigurationFile {
|
||||
version: '1.0.0'
|
||||
info: Record<string, string>
|
||||
configuration: HomeServerEnvironmentConfiguration
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import path from 'path'
|
||||
|
||||
import {
|
||||
HomeServerManagerInterface,
|
||||
HomeServerEnvironmentConfiguration,
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { HomeServerInterface } from '@standardnotes/home-server'
|
||||
|
||||
import { WebContents } from 'electron'
|
||||
import { MessageToWebApp } from '../../Shared/IpcMessages'
|
||||
import { FilesManagerInterface } from '../File/FilesManagerInterface'
|
||||
import { HomeServerConfigurationFile } from './HomeServerConfigurationFile'
|
||||
|
||||
const os = require('os')
|
||||
|
||||
export class HomeServerManager implements HomeServerManagerInterface {
|
||||
private readonly HOME_SERVER_CONFIGURATION_FILE_NAME = 'config.json'
|
||||
|
||||
private homeServerConfiguration: HomeServerEnvironmentConfiguration | undefined
|
||||
private homeServerDataLocation: string | undefined
|
||||
private lastErrorMessage: string | undefined
|
||||
private logs: string[] = []
|
||||
|
||||
private readonly LOGS_BUFFER_SIZE = 1000
|
||||
|
||||
constructor(
|
||||
private homeServer: HomeServerInterface,
|
||||
private webContents: WebContents,
|
||||
private filesManager: FilesManagerInterface,
|
||||
) {}
|
||||
|
||||
async getHomeServerUrl(): Promise<string | undefined> {
|
||||
const homeServerConfiguration = await this.getHomeServerConfigurationObject()
|
||||
if (!homeServerConfiguration) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return `http://${this.getLocalIP()}:${homeServerConfiguration.port}`
|
||||
}
|
||||
|
||||
async isHomeServerRunning(): Promise<boolean> {
|
||||
return this.homeServer.isRunning()
|
||||
}
|
||||
|
||||
async getHomeServerLastErrorMessage(): Promise<string | undefined> {
|
||||
return this.lastErrorMessage
|
||||
}
|
||||
|
||||
async activatePremiumFeatures(username: string): Promise<string | undefined> {
|
||||
const result = await this.homeServer.activatePremiumFeatures(username)
|
||||
|
||||
if (result.isFailed()) {
|
||||
return result.getError()
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeServerConfiguration(): Promise<string | undefined> {
|
||||
if (this.homeServerConfiguration) {
|
||||
return JSON.stringify(this.homeServerConfiguration)
|
||||
}
|
||||
|
||||
if (!this.homeServerDataLocation) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const homeServerConfiguration = await this.filesManager.readJSONFile<HomeServerConfigurationFile>(
|
||||
path.join(this.homeServerDataLocation, this.HOME_SERVER_CONFIGURATION_FILE_NAME),
|
||||
)
|
||||
if (!homeServerConfiguration) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return JSON.stringify(homeServerConfiguration.configuration)
|
||||
}
|
||||
|
||||
async setHomeServerConfiguration(configurationJSONString: string): Promise<void> {
|
||||
try {
|
||||
if (!this.homeServerDataLocation) {
|
||||
throw new Error('Home server data location is not set.')
|
||||
}
|
||||
|
||||
const homeServerConfiguration = JSON.parse(configurationJSONString) as HomeServerEnvironmentConfiguration
|
||||
|
||||
await this.filesManager.ensureDirectoryExists(this.homeServerDataLocation)
|
||||
|
||||
const configurationFile: HomeServerConfigurationFile = {
|
||||
version: '1.0.0',
|
||||
info: {
|
||||
warning: 'Do not edit this file.',
|
||||
information:
|
||||
'The values below are encrypted with a key created by the desktop application after installation. The key is stored in your secure device keychain.',
|
||||
instructions:
|
||||
'Put this file inside your home server data location to restore your home server configuration.',
|
||||
},
|
||||
configuration: homeServerConfiguration,
|
||||
}
|
||||
|
||||
await this.filesManager.writeJSONFile(
|
||||
path.join(this.homeServerDataLocation, this.HOME_SERVER_CONFIGURATION_FILE_NAME),
|
||||
configurationFile,
|
||||
)
|
||||
|
||||
this.homeServerConfiguration = homeServerConfiguration
|
||||
} catch (error) {
|
||||
console.error(`Could not save server configuration: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async setHomeServerDataLocation(location: string): Promise<void> {
|
||||
this.homeServerDataLocation = location
|
||||
}
|
||||
|
||||
async stopHomeServer(): Promise<string | undefined> {
|
||||
const result = await this.homeServer.stop()
|
||||
if (result.isFailed()) {
|
||||
return result.getError()
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async startHomeServer(): Promise<string | undefined> {
|
||||
try {
|
||||
this.lastErrorMessage = undefined
|
||||
this.logs = []
|
||||
|
||||
let homeServerConfiguration = await this.getHomeServerConfigurationObject()
|
||||
if (!homeServerConfiguration) {
|
||||
homeServerConfiguration = this.generateHomeServerConfiguration()
|
||||
}
|
||||
await this.setHomeServerConfiguration(JSON.stringify(homeServerConfiguration))
|
||||
|
||||
if (!this.homeServerDataLocation) {
|
||||
this.lastErrorMessage = 'Home server data location is not set.'
|
||||
|
||||
return this.lastErrorMessage
|
||||
}
|
||||
|
||||
const {
|
||||
jwtSecret,
|
||||
authJwtSecret,
|
||||
encryptionServerKey,
|
||||
pseudoKeyParamsKey,
|
||||
valetTokenSecret,
|
||||
port,
|
||||
logLevel,
|
||||
databaseEngine,
|
||||
mysqlConfiguration,
|
||||
} = homeServerConfiguration as HomeServerEnvironmentConfiguration
|
||||
|
||||
const environment: { [name: string]: string } = {
|
||||
JWT_SECRET: jwtSecret,
|
||||
AUTH_JWT_SECRET: authJwtSecret,
|
||||
ENCRYPTION_SERVER_KEY: encryptionServerKey,
|
||||
PSEUDO_KEY_PARAMS_KEY: pseudoKeyParamsKey,
|
||||
VALET_TOKEN_SECRET: valetTokenSecret,
|
||||
FILES_SERVER_URL: (await this.getHomeServerUrl()) as string,
|
||||
LOG_LEVEL: logLevel ?? 'info',
|
||||
VERSION: 'desktop',
|
||||
PORT: port.toString(),
|
||||
DB_TYPE: databaseEngine,
|
||||
}
|
||||
|
||||
if (mysqlConfiguration !== undefined) {
|
||||
environment.DB_HOST = mysqlConfiguration.host
|
||||
if (mysqlConfiguration.port) {
|
||||
environment.DB_PORT = mysqlConfiguration.port.toString()
|
||||
}
|
||||
environment.DB_USERNAME = mysqlConfiguration.username
|
||||
environment.DB_PASSWORD = mysqlConfiguration.password
|
||||
environment.DB_DATABASE = mysqlConfiguration.database
|
||||
}
|
||||
|
||||
const result = await this.homeServer.start({
|
||||
dataDirectoryPath: this.homeServerDataLocation,
|
||||
environment,
|
||||
logStreamCallback: this.appendLogs.bind(this),
|
||||
})
|
||||
|
||||
if (result.isFailed()) {
|
||||
this.lastErrorMessage = result.getError()
|
||||
|
||||
return this.lastErrorMessage
|
||||
}
|
||||
|
||||
this.webContents.send(MessageToWebApp.HomeServerStarted, await this.getHomeServerUrl())
|
||||
} catch (error) {
|
||||
return (error as Error).message
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeServerLogs(): Promise<string[]> {
|
||||
return this.logs
|
||||
}
|
||||
|
||||
private appendLogs(log: Buffer): void {
|
||||
this.logs.push(log.toString())
|
||||
|
||||
if (this.logs.length > this.LOGS_BUFFER_SIZE) {
|
||||
this.logs.shift()
|
||||
}
|
||||
}
|
||||
|
||||
private generateRandomKey(length: number): string {
|
||||
return require('crypto').randomBytes(length).toString('hex')
|
||||
}
|
||||
|
||||
private getLocalIP() {
|
||||
const interfaces = os.networkInterfaces()
|
||||
for (const interfaceName in interfaces) {
|
||||
const addresses = interfaces[interfaceName]
|
||||
for (const address of addresses) {
|
||||
if (address.family === 'IPv4' && !address.internal) {
|
||||
return address.address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getHomeServerConfigurationObject(): Promise<HomeServerEnvironmentConfiguration | undefined> {
|
||||
try {
|
||||
const homeServerConfigurationJSON = await this.getHomeServerConfiguration()
|
||||
if (!homeServerConfigurationJSON) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return JSON.parse(homeServerConfigurationJSON)
|
||||
} catch (error) {
|
||||
console.error(`Could not get home server configuration: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private generateHomeServerConfiguration(): HomeServerEnvironmentConfiguration {
|
||||
const jwtSecret = this.generateRandomKey(32)
|
||||
const authJwtSecret = this.generateRandomKey(32)
|
||||
const encryptionServerKey = this.generateRandomKey(32)
|
||||
const pseudoKeyParamsKey = this.generateRandomKey(32)
|
||||
const valetTokenSecret = this.generateRandomKey(32)
|
||||
const port = 3127
|
||||
|
||||
const configuration: HomeServerEnvironmentConfiguration = {
|
||||
jwtSecret,
|
||||
authJwtSecret,
|
||||
encryptionServerKey,
|
||||
pseudoKeyParamsKey,
|
||||
valetTokenSecret,
|
||||
port,
|
||||
databaseEngine: 'sqlite',
|
||||
logLevel: 'info',
|
||||
}
|
||||
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import path from 'path'
|
||||
import { pipeline as pipelineFn } from 'stream'
|
||||
import { promisify } from 'util'
|
||||
import { MessageType } from '../../../../test/TestIpcMessage'
|
||||
import { ensureDirectoryExists } from '../Utils/FileUtils'
|
||||
import { handleTestMessage } from '../Utils/Testing'
|
||||
import { isTesting } from '../Utils/Utils'
|
||||
import { FilesManager } from '../File/FilesManager'
|
||||
|
||||
const pipeline = promisify(pipelineFn)
|
||||
|
||||
@@ -21,7 +21,7 @@ if (isTesting()) {
|
||||
* not exist)
|
||||
*/
|
||||
export async function downloadFile(url: string, filePath: string): Promise<void> {
|
||||
await ensureDirectoryExists(path.dirname(filePath))
|
||||
await new FilesManager().ensureDirectoryExists(path.dirname(filePath))
|
||||
const response = await get(url)
|
||||
await pipeline(
|
||||
/**
|
||||
|
||||
@@ -5,19 +5,12 @@ import path from 'path'
|
||||
import { MessageToWebApp } from '../../Shared/IpcMessages'
|
||||
import { AppName } from '../Strings'
|
||||
import { Paths } from '../Types/Paths'
|
||||
import {
|
||||
debouncedJSONDiskWriter,
|
||||
deleteDir,
|
||||
deleteDirContents,
|
||||
ensureDirectoryExists,
|
||||
extractZip,
|
||||
FileDoesNotExist,
|
||||
moveDirContents,
|
||||
readJSONFile,
|
||||
} from '../Utils/FileUtils'
|
||||
import { timeout } from '../Utils/Utils'
|
||||
import { downloadFile, getJSON } from './Networking'
|
||||
import { Component, MappingFile, PackageInfo, PackageManagerInterface, SyncTask } from './PackageManagerInterface'
|
||||
import { FilesManagerInterface } from '../File/FilesManagerInterface'
|
||||
import { FilesManager } from '../File/FilesManager'
|
||||
import { FileErrorCodes } from '../File/FileErrorCodes'
|
||||
|
||||
function logMessage(...message: any) {
|
||||
log.info('PackageManager[Info]:', ...message)
|
||||
@@ -33,26 +26,27 @@ function logError(...message: any) {
|
||||
class MappingFileHandler {
|
||||
static async create() {
|
||||
let mapping: MappingFile
|
||||
const filesManager = new FilesManager()
|
||||
|
||||
try {
|
||||
const result = await readJSONFile<MappingFile>(Paths.extensionsMappingJson)
|
||||
const result = await filesManager.readJSONFile<MappingFile>(Paths.extensionsMappingJson)
|
||||
mapping = result || {}
|
||||
} catch (error: any) {
|
||||
/**
|
||||
* Mapping file might be absent (first start, corrupted data)
|
||||
*/
|
||||
if (error.code === FileDoesNotExist) {
|
||||
await ensureDirectoryExists(path.dirname(Paths.extensionsMappingJson))
|
||||
if (error.code === FileErrorCodes.FileDoesNotExist) {
|
||||
await filesManager.ensureDirectoryExists(path.dirname(Paths.extensionsMappingJson))
|
||||
} else {
|
||||
logError(error)
|
||||
}
|
||||
mapping = {}
|
||||
}
|
||||
|
||||
return new MappingFileHandler(mapping)
|
||||
return new MappingFileHandler(mapping, filesManager)
|
||||
}
|
||||
|
||||
constructor(private mapping: MappingFile) {}
|
||||
constructor(private mapping: MappingFile, private filesManager: FilesManagerInterface) {}
|
||||
|
||||
get = (componendId: string) => {
|
||||
return this.mapping[componendId]
|
||||
@@ -63,12 +57,14 @@ class MappingFileHandler {
|
||||
location,
|
||||
version,
|
||||
}
|
||||
this.writeToDisk()
|
||||
|
||||
this.filesManager.debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
|
||||
}
|
||||
|
||||
remove = (componentId: string) => {
|
||||
delete this.mapping[componentId]
|
||||
this.writeToDisk()
|
||||
|
||||
this.filesManager.debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
|
||||
}
|
||||
|
||||
getInstalledVersionForComponent = async (component: Component): Promise<string> => {
|
||||
@@ -83,15 +79,13 @@ class MappingFileHandler {
|
||||
*/
|
||||
const paths = pathsForComponent(component)
|
||||
const packagePath = path.join(paths.absolutePath, 'package.json')
|
||||
const response = await readJSONFile<{ version: string }>(packagePath)
|
||||
const response = await this.filesManager.readJSONFile<{ version: string }>(packagePath)
|
||||
if (!response) {
|
||||
return ''
|
||||
}
|
||||
this.set(component.uuid, paths.relativePath, response.version)
|
||||
return response.version
|
||||
}
|
||||
|
||||
private writeToDisk = debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
|
||||
}
|
||||
|
||||
export async function initializePackageManager(webContents: Electron.WebContents): Promise<PackageManagerInterface> {
|
||||
@@ -230,7 +224,7 @@ async function syncComponents(webContents: Electron.WebContents, mapping: Mappin
|
||||
await checkForUpdate(webContents, mapping, component)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === FileDoesNotExist) {
|
||||
if (error.code === FileErrorCodes.FileDoesNotExist) {
|
||||
/** We have a component but no content. Install the component */
|
||||
await installComponent(webContents, mapping, component, component.content.package_info, version)
|
||||
} else {
|
||||
@@ -289,7 +283,7 @@ async function unnestLegacyStructure(dir: string) {
|
||||
const sourceDir = path.join(dir, fileNames[0])
|
||||
const destDir = dir
|
||||
|
||||
await moveDirContents(sourceDir, destDir)
|
||||
await new FilesManager().moveDirContents(sourceDir, destDir)
|
||||
}
|
||||
|
||||
async function installComponent(
|
||||
@@ -331,13 +325,15 @@ async function installComponent(
|
||||
downloadFile(downloadUrl, paths.downloadPath),
|
||||
(async () => {
|
||||
/** Clear the component's directory before extracting the zip. */
|
||||
await ensureDirectoryExists(paths.absolutePath)
|
||||
await deleteDirContents(paths.absolutePath)
|
||||
const filesManager = new FilesManager()
|
||||
await filesManager.ensureDirectoryExists(paths.absolutePath)
|
||||
await filesManager.deleteDirContents(paths.absolutePath)
|
||||
})(),
|
||||
])
|
||||
|
||||
logMessage('Extracting', paths.downloadPath, 'to', paths.absolutePath)
|
||||
await extractZip(paths.downloadPath, paths.absolutePath)
|
||||
const filesManager = new FilesManager()
|
||||
await filesManager.extractZip(paths.downloadPath, paths.absolutePath)
|
||||
|
||||
const legacyStructure = await usesLegacyNestedFolderStructure(paths.absolutePath)
|
||||
if (legacyStructure) {
|
||||
@@ -348,7 +344,7 @@ async function installComponent(
|
||||
try {
|
||||
/** Try to read 'sn.main' field from 'package.json' file */
|
||||
const packageJsonPath = path.join(paths.absolutePath, 'package.json')
|
||||
const packageJson = await readJSONFile<{
|
||||
const packageJson = await filesManager.readJSONFile<{
|
||||
sn?: { main?: string }
|
||||
version?: string
|
||||
}>(packageJsonPath)
|
||||
@@ -403,6 +399,8 @@ async function uninstallComponent(mapping: MappingFileHandler, uuid: string) {
|
||||
/** No mapping for component */
|
||||
return
|
||||
}
|
||||
await deleteDir(path.join(Paths.userDataDir, componentMapping.location))
|
||||
mapping.remove(uuid)
|
||||
const result = await new FilesManager().deleteDir(path.join(Paths.userDataDir, componentMapping.location))
|
||||
if (!result.isFailed()) {
|
||||
mapping.remove(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
FileBackupsMapping,
|
||||
FileBackupReadToken,
|
||||
FileBackupReadChunkResponse,
|
||||
HomeServerManagerInterface,
|
||||
PlaintextBackupsMapping,
|
||||
DirectoryManagerInterface,
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { KeychainInterface } from '../Keychain/KeychainInterface'
|
||||
@@ -34,6 +36,8 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
private menus: MenuManagerInterface,
|
||||
private fileBackups: FileBackupsDevice,
|
||||
private media: MediaManagerInterface,
|
||||
private homeServerManager: HomeServerManagerInterface,
|
||||
private directoryManager: DirectoryManagerInterface,
|
||||
) {}
|
||||
|
||||
get exposableValue(): CrossProcessBridge {
|
||||
@@ -63,6 +67,8 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
getFileBackupReadToken: this.getFileBackupReadToken.bind(this),
|
||||
readNextChunk: this.readNextChunk.bind(this),
|
||||
askForMediaAccess: this.askForMediaAccess.bind(this),
|
||||
startHomeServer: this.startHomeServer.bind(this),
|
||||
stopHomeServer: this.stopHomeServer.bind(this),
|
||||
wasLegacyTextBackupsExplicitlyDisabled: this.wasLegacyTextBackupsExplicitlyDisabled.bind(this),
|
||||
getLegacyTextBackupsLocation: this.getLegacyTextBackupsLocation.bind(this),
|
||||
saveTextBackupData: this.saveTextBackupData.bind(this),
|
||||
@@ -70,6 +76,7 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
openLocation: this.openLocation.bind(this),
|
||||
presentDirectoryPickerForLocationChangeAndTransferOld:
|
||||
this.presentDirectoryPickerForLocationChangeAndTransferOld.bind(this),
|
||||
getDirectoryManagerLastErrorMessage: this.getDirectoryManagerLastErrorMessage.bind(this),
|
||||
getPlaintextBackupsMappingFile: this.getPlaintextBackupsMappingFile.bind(this),
|
||||
persistPlaintextBackupsMappingFile: this.persistPlaintextBackupsMappingFile.bind(this),
|
||||
getTextBackupsCount: this.getTextBackupsCount.bind(this),
|
||||
@@ -77,6 +84,14 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
getUserDocumentsDirectory: this.getUserDocumentsDirectory.bind(this),
|
||||
monitorPlaintextBackupsLocationForChanges: this.monitorPlaintextBackupsLocationForChanges.bind(this),
|
||||
joinPaths: this.joinPaths.bind(this),
|
||||
setHomeServerConfiguration: this.setHomeServerConfiguration.bind(this),
|
||||
getHomeServerConfiguration: this.getHomeServerConfiguration.bind(this),
|
||||
setHomeServerDataLocation: this.setHomeServerDataLocation.bind(this),
|
||||
activatePremiumFeatures: this.activatePremiumFeatures.bind(this),
|
||||
isHomeServerRunning: this.isHomeServerRunning.bind(this),
|
||||
getHomeServerLogs: this.getHomeServerLogs.bind(this),
|
||||
getHomeServerUrl: this.getHomeServerUrl.bind(this),
|
||||
getHomeServerLastErrorMessage: this.getHomeServerLastErrorMessage.bind(this),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,15 +216,19 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
return this.fileBackups.savePlaintextNoteBackup(location, uuid, name, tags, data)
|
||||
}
|
||||
|
||||
openLocation(path: string): Promise<void> {
|
||||
return this.fileBackups.openLocation(path)
|
||||
async openLocation(path: string): Promise<void> {
|
||||
return this.directoryManager.openLocation(path)
|
||||
}
|
||||
|
||||
presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
async presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
appendPath: string,
|
||||
oldLocation?: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
return this.fileBackups.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation)
|
||||
return this.directoryManager.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation)
|
||||
}
|
||||
|
||||
async getDirectoryManagerLastErrorMessage(): Promise<string | undefined> {
|
||||
return this.directoryManager.getDirectoryManagerLastErrorMessage()
|
||||
}
|
||||
|
||||
getPlaintextBackupsMappingFile(location: string): Promise<PlaintextBackupsMapping> {
|
||||
@@ -243,4 +262,44 @@ export class RemoteBridge implements CrossProcessBridge {
|
||||
askForMediaAccess(type: 'camera' | 'microphone'): Promise<boolean> {
|
||||
return this.media.askForMediaAccess(type)
|
||||
}
|
||||
|
||||
async startHomeServer(): Promise<string | undefined> {
|
||||
return this.homeServerManager.startHomeServer()
|
||||
}
|
||||
|
||||
async stopHomeServer(): Promise<string | undefined> {
|
||||
return this.homeServerManager.stopHomeServer()
|
||||
}
|
||||
|
||||
async setHomeServerConfiguration(configurationJSONString: string): Promise<void> {
|
||||
return this.homeServerManager.setHomeServerConfiguration(configurationJSONString)
|
||||
}
|
||||
|
||||
async getHomeServerConfiguration(): Promise<string | undefined> {
|
||||
return this.homeServerManager.getHomeServerConfiguration()
|
||||
}
|
||||
|
||||
async setHomeServerDataLocation(location: string): Promise<void> {
|
||||
return this.homeServerManager.setHomeServerDataLocation(location)
|
||||
}
|
||||
|
||||
async activatePremiumFeatures(username: string): Promise<string | undefined> {
|
||||
return this.homeServerManager.activatePremiumFeatures(username)
|
||||
}
|
||||
|
||||
async isHomeServerRunning(): Promise<boolean> {
|
||||
return this.homeServerManager.isHomeServerRunning()
|
||||
}
|
||||
|
||||
async getHomeServerLogs(): Promise<string[]> {
|
||||
return this.homeServerManager.getHomeServerLogs()
|
||||
}
|
||||
|
||||
async getHomeServerUrl(): Promise<string | undefined> {
|
||||
return this.homeServerManager.getHomeServerUrl()
|
||||
}
|
||||
|
||||
async getHomeServerLastErrorMessage(): Promise<string | undefined> {
|
||||
return this.homeServerManager.getHomeServerLastErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export enum StoreKeys {
|
||||
UseNativeKeychain = 'useNativeKeychain',
|
||||
LastRunVersion = 'LastRunVersion',
|
||||
|
||||
HomeServerDataLocation = 'HomeServerDataLocation',
|
||||
|
||||
LegacyTextBackupsLocation = 'backupsLocation',
|
||||
LegacyTextBackupsDisabled = 'backupsDisabled',
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from 'fs'
|
||||
import { Language } from '../SpellcheckerManager'
|
||||
import { FileDoesNotExist } from '../Utils/FileUtils'
|
||||
import { ensureIsBoolean, isBoolean } from '../Utils/Utils'
|
||||
import { StoreData, StoreKeys } from './StoreKeys'
|
||||
import { logError } from './Store'
|
||||
import { FileErrorCodes } from '../File/FileErrorCodes'
|
||||
|
||||
export function createSanitizedStoreData(data: any = {}): StoreData {
|
||||
return {
|
||||
@@ -69,7 +69,7 @@ export function parseDataFile(filePath: string) {
|
||||
return createSanitizedStoreData(userData)
|
||||
} catch (error: any) {
|
||||
console.log('Error reading store file', error)
|
||||
if (error.code !== FileDoesNotExist) {
|
||||
if (error.code !== FileErrorCodes.FileDoesNotExist) {
|
||||
logError(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import { dialog } from 'electron'
|
||||
import fs, { PathLike } from 'fs'
|
||||
import { debounce } from 'lodash'
|
||||
import path from 'path'
|
||||
import yauzl from 'yauzl'
|
||||
import { removeFromArray } from '../Utils/Utils'
|
||||
|
||||
export const FileDoesNotExist = 'ENOENT'
|
||||
export const FileAlreadyExists = 'EEXIST'
|
||||
const OperationNotPermitted = 'EPERM'
|
||||
const DeviceIsBusy = 'EBUSY'
|
||||
|
||||
export function debouncedJSONDiskWriter(durationMs: number, location: string, data: () => unknown): () => void {
|
||||
let writingToDisk = false
|
||||
return debounce(async () => {
|
||||
if (writingToDisk) {
|
||||
return
|
||||
}
|
||||
writingToDisk = true
|
||||
try {
|
||||
await writeJSONFile(location, data())
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
writingToDisk = false
|
||||
}
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
export async function openDirectoryPicker(buttonLabel?: string): Promise<string | undefined> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'],
|
||||
buttonLabel: buttonLabel,
|
||||
})
|
||||
|
||||
return result.filePaths[0]
|
||||
}
|
||||
|
||||
export async function readJSONFile<T>(filepath: string): Promise<T | undefined> {
|
||||
try {
|
||||
const data = await fs.promises.readFile(filepath, 'utf8')
|
||||
return JSON.parse(data)
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function readJSONFileSync<T>(filepath: string): T {
|
||||
const data = fs.readFileSync(filepath, 'utf8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
|
||||
export async function writeJSONFile(filepath: string, data: unknown): Promise<void> {
|
||||
await ensureDirectoryExists(path.dirname(filepath))
|
||||
await fs.promises.writeFile(filepath, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
export async function writeFile(filepath: string, data: string): Promise<void> {
|
||||
await ensureDirectoryExists(path.dirname(filepath))
|
||||
await fs.promises.writeFile(filepath, data, 'utf8')
|
||||
}
|
||||
|
||||
export function writeJSONFileSync(filepath: string, data: unknown): void {
|
||||
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
/** Creates the directory if it doesn't exist. */
|
||||
export async function ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.promises.lstat(dirPath)
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('Tried to create a directory where a file of the same ' + `name already exists: ${dirPath}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === FileDoesNotExist) {
|
||||
/**
|
||||
* No directory here. Make sure there is a *parent* directory, and then
|
||||
* create it.
|
||||
*/
|
||||
await ensureDirectoryExists(path.dirname(dirPath))
|
||||
|
||||
/** Now that its parent(s) exist, create the directory */
|
||||
try {
|
||||
await fs.promises.mkdir(dirPath)
|
||||
} catch (error: any) {
|
||||
if (error.code === FileAlreadyExists) {
|
||||
/**
|
||||
* A concurrent process must have created the directory already.
|
||||
* Make sure it *is* a directory and not something else.
|
||||
*/
|
||||
await ensureDirectoryExists(dirPath)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a directory (handling recursion.)
|
||||
* @param {string} dirPath the path of the directory
|
||||
*/
|
||||
export async function deleteDir(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await deleteDirContents(dirPath)
|
||||
} catch (error: any) {
|
||||
if (error.code === FileDoesNotExist) {
|
||||
/** Directory has already been deleted. */
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await fs.promises.rmdir(dirPath)
|
||||
}
|
||||
|
||||
export async function deleteDirContents(dirPath: string): Promise<void> {
|
||||
/**
|
||||
* Scan the directory up to ten times, to handle cases where files are being added while
|
||||
* the directory's contents are being deleted
|
||||
*/
|
||||
for (let i = 1, maxTries = 10; i < maxTries; i++) {
|
||||
const children = await fs.promises.readdir(dirPath, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
|
||||
if (children.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
const childPath = path.join(dirPath, child.name)
|
||||
if (child.isDirectory()) {
|
||||
await deleteDirContents(childPath)
|
||||
try {
|
||||
await fs.promises.rmdir(childPath)
|
||||
} catch (error) {
|
||||
if (error !== FileDoesNotExist) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await deleteFile(childPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isChildOfDir(parent: string, potentialChild: string) {
|
||||
const relative = path.relative(parent, potentialChild)
|
||||
return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
export async function moveDirContents(srcDir: string, destDir: string): Promise<void> {
|
||||
let fileNames: string[]
|
||||
try {
|
||||
fileNames = await fs.promises.readdir(srcDir)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
await ensureDirectoryExists(destDir)
|
||||
|
||||
if (isChildOfDir(srcDir, destDir)) {
|
||||
fileNames = fileNames.filter((name) => {
|
||||
return !isChildOfDir(destDir, path.join(srcDir, name))
|
||||
})
|
||||
removeFromArray(fileNames, path.basename(destDir))
|
||||
}
|
||||
|
||||
try {
|
||||
await moveFiles(
|
||||
fileNames.map((fileName) => path.join(srcDir, fileName)),
|
||||
destDir,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractZip(source: string, dest: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(source, { lazyEntries: true, autoClose: true }, (err, zipFile) => {
|
||||
let cancelled = false
|
||||
|
||||
const tryReject = (err: Error) => {
|
||||
if (!cancelled) {
|
||||
cancelled = true
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return tryReject(err)
|
||||
}
|
||||
|
||||
if (!zipFile) {
|
||||
return tryReject(new Error('zipFile === undefined'))
|
||||
}
|
||||
|
||||
zipFile.readEntry()
|
||||
|
||||
zipFile.on('close', resolve)
|
||||
|
||||
zipFile.on('entry', (entry) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const isEntryDirectory = entry.fileName.endsWith('/')
|
||||
if (isEntryDirectory) {
|
||||
zipFile.readEntry()
|
||||
return
|
||||
}
|
||||
|
||||
zipFile.openReadStream(entry, async (err, stream) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return tryReject(err)
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
return tryReject(new Error('stream === undefined'))
|
||||
}
|
||||
|
||||
stream.on('error', tryReject)
|
||||
|
||||
const filepath = path.join(dest, entry.fileName)
|
||||
|
||||
try {
|
||||
await ensureDirectoryExists(path.dirname(filepath))
|
||||
} catch (error: any) {
|
||||
return tryReject(error)
|
||||
}
|
||||
const writeStream = fs.createWriteStream(filepath).on('error', tryReject).on('error', tryReject)
|
||||
|
||||
stream.pipe(writeStream).on('close', () => {
|
||||
zipFile.readEntry()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function moveFiles(sources: string[], destDir: string): Promise<void[]> {
|
||||
await ensureDirectoryExists(destDir)
|
||||
return Promise.all(sources.map((fileName) => moveFile(fileName, path.join(destDir, path.basename(fileName)))))
|
||||
}
|
||||
|
||||
export async function moveFile(source: PathLike, destination: PathLike) {
|
||||
try {
|
||||
await fs.promises.rename(source, destination)
|
||||
} catch (_error) {
|
||||
/** Fall back to copying and then deleting. */
|
||||
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_FICLONE_FORCE)
|
||||
await fs.promises.unlink(source)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileIfExists(filePath: PathLike): Promise<void> {
|
||||
try {
|
||||
await deleteFile(filePath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a file, handling EPERM and EBUSY errors on Windows. */
|
||||
export async function deleteFile(filePath: PathLike): Promise<void> {
|
||||
for (let i = 1, maxTries = 10; i < maxTries; i++) {
|
||||
try {
|
||||
await fs.promises.unlink(filePath)
|
||||
break
|
||||
} catch (error: any) {
|
||||
if (error.code === OperationNotPermitted || error.code === DeviceIsBusy) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
continue
|
||||
} else if (error.code === FileDoesNotExist) {
|
||||
/** Already deleted */
|
||||
break
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ import { checkForUpdate, setupUpdates } from './UpdateManager'
|
||||
import { handleTestMessage, send } from './Utils/Testing'
|
||||
import { isTesting, lowercaseDriveLetter } from './Utils/Utils'
|
||||
import { initializeZoomManager } from './ZoomManager'
|
||||
import { HomeServerManager } from './HomeServer/HomeServerManager'
|
||||
import { HomeServer } from '@standardnotes/home-server'
|
||||
import { FilesManager } from './File/FilesManager'
|
||||
import { DirectoryManager } from './Directory/DirectoryManager'
|
||||
|
||||
const WINDOW_DEFAULT_WIDTH = 1100
|
||||
const WINDOW_DEFAULT_HEIGHT = 800
|
||||
@@ -72,6 +76,8 @@ export async function createWindowState({
|
||||
services.menuManager,
|
||||
services.fileBackupsManager,
|
||||
services.mediaManager,
|
||||
services.homeServerManager,
|
||||
services.directoryManager,
|
||||
)
|
||||
|
||||
const shouldOpenUrl = (url: string) => url.startsWith('http') || url.startsWith('mailto')
|
||||
@@ -200,6 +206,11 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
|
||||
const trayManager = createTrayManager(window, appState.store)
|
||||
const spellcheckerManager = createSpellcheckerManager(appState.store, window.webContents, appLocale)
|
||||
const mediaManager = new MediaManager()
|
||||
const homeServer = new HomeServer()
|
||||
const filesManager = new FilesManager()
|
||||
const directoryManager = new DirectoryManager(filesManager)
|
||||
|
||||
const homeServerManager = new HomeServerManager(homeServer, window.webContents, filesManager)
|
||||
|
||||
if (isTesting()) {
|
||||
handleTestMessage(MessageType.SpellCheckerManager, () => spellcheckerManager)
|
||||
@@ -213,7 +224,7 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
|
||||
spellcheckerManager,
|
||||
})
|
||||
|
||||
const fileBackupsManager = new FilesBackupManager(appState, window.webContents)
|
||||
const fileBackupsManager = new FilesBackupManager(appState, window.webContents, filesManager)
|
||||
|
||||
return {
|
||||
updateManager,
|
||||
@@ -224,6 +235,8 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
|
||||
searchManager,
|
||||
fileBackupsManager,
|
||||
mediaManager,
|
||||
homeServerManager,
|
||||
directoryManager,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { FileBackupsDevice } from '@web/Application/Device/DesktopSnjsExports'
|
||||
import {
|
||||
DirectoryManagerInterface,
|
||||
FileBackupsDevice,
|
||||
HomeServerManagerInterface,
|
||||
} from '@web/Application/Device/DesktopSnjsExports'
|
||||
import { Component } from '../Main/Packages/PackageManagerInterface'
|
||||
|
||||
export interface CrossProcessBridge extends FileBackupsDevice {
|
||||
export interface CrossProcessBridge extends FileBackupsDevice, DirectoryManagerInterface, HomeServerManagerInterface {
|
||||
get extServerHost(): string
|
||||
get useNativeKeychain(): boolean
|
||||
get rendererPath(): string
|
||||
|
||||
@@ -25,6 +25,46 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn
|
||||
super(appVersion)
|
||||
}
|
||||
|
||||
async getHomeServerUrl(): Promise<string | undefined> {
|
||||
return this.remoteBridge.getHomeServerUrl()
|
||||
}
|
||||
|
||||
async getHomeServerLastErrorMessage(): Promise<string | undefined> {
|
||||
return this.remoteBridge.getHomeServerLastErrorMessage()
|
||||
}
|
||||
|
||||
async isHomeServerRunning(): Promise<boolean> {
|
||||
return this.remoteBridge.isHomeServerRunning()
|
||||
}
|
||||
|
||||
async activatePremiumFeatures(username: string): Promise<string | undefined> {
|
||||
return this.remoteBridge.activatePremiumFeatures(username)
|
||||
}
|
||||
|
||||
async setHomeServerConfiguration(configurationJSONString: string): Promise<void> {
|
||||
return this.remoteBridge.setHomeServerConfiguration(configurationJSONString)
|
||||
}
|
||||
|
||||
async getHomeServerConfiguration(): Promise<string | undefined> {
|
||||
return this.remoteBridge.getHomeServerConfiguration()
|
||||
}
|
||||
|
||||
async setHomeServerDataLocation(location: string): Promise<void> {
|
||||
return this.remoteBridge.setHomeServerDataLocation(location)
|
||||
}
|
||||
|
||||
startHomeServer(): Promise<string | undefined> {
|
||||
return this.remoteBridge.startHomeServer()
|
||||
}
|
||||
|
||||
stopHomeServer(): Promise<string | undefined> {
|
||||
return this.remoteBridge.stopHomeServer()
|
||||
}
|
||||
|
||||
getHomeServerLogs(): Promise<string[]> {
|
||||
return this.remoteBridge.getHomeServerLogs()
|
||||
}
|
||||
|
||||
openLocation(path: string): Promise<void> {
|
||||
return this.remoteBridge.openLocation(path)
|
||||
}
|
||||
@@ -36,6 +76,10 @@ export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceIn
|
||||
return this.remoteBridge.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation)
|
||||
}
|
||||
|
||||
getDirectoryManagerLastErrorMessage(): Promise<string | undefined> {
|
||||
return this.remoteBridge.getDirectoryManagerLastErrorMessage()
|
||||
}
|
||||
|
||||
getFilesBackupsMappingFile(location: string): Promise<FileBackupsMapping> {
|
||||
return this.remoteBridge.getFilesBackupsMappingFile(location)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ process.once('loaded', function () {
|
||||
|
||||
setInstallComponentCompleteHandler: (handler: MainEventHandler) =>
|
||||
ipcRenderer.on(MessageToWebApp.InstallComponentComplete, handler),
|
||||
|
||||
setHomeServerStartedHandler: (handler: MainEventHandler) =>
|
||||
ipcRenderer.on(MessageToWebApp.HomeServerStarted, handler),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronMainEvents', mainEvents)
|
||||
|
||||
@@ -54,6 +54,12 @@ window.onload = () => {
|
||||
void loadAndStartApplication()
|
||||
}
|
||||
|
||||
window.onunload = () => {
|
||||
if (window.device) {
|
||||
void window.device.stopHomeServer()
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns whether the keychain structure is up to date or not */
|
||||
async function migrateKeychain(remoteBridge: CrossProcessBridge): Promise<boolean> {
|
||||
if (!remoteBridge.useNativeKeychain) {
|
||||
@@ -151,3 +157,7 @@ window.electronMainEvents.setInstallComponentCompleteHandler((_: IpcRendererEven
|
||||
window.electronMainEvents.setWatchedDirectoriesChangeHandler((_: IpcRendererEvent, changes: unknown) => {
|
||||
void window.webClient.handleWatchedDirectoriesChanges(changes as DesktopWatchedDirectoriesChanges)
|
||||
})
|
||||
|
||||
window.electronMainEvents.setHomeServerStartedHandler((_: IpcRendererEvent, serverUrl: unknown) => {
|
||||
void window.webClient.handleHomeServerStarted(serverUrl as string)
|
||||
})
|
||||
|
||||
@@ -8,4 +8,5 @@ export interface ElectronMainEvents {
|
||||
setWindowFocusedHandler(handler: MainEventHandler): void
|
||||
setInstallComponentCompleteHandler(handler: MainEventHandler): void
|
||||
setWatchedDirectoriesChangeHandler(handler: MainEventHandler): void
|
||||
setHomeServerStartedHandler(handler: MainEventHandler): void
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum MessageToWebApp {
|
||||
WindowFocused = 'window-focused',
|
||||
InstallComponentComplete = 'install-component-complete',
|
||||
WatchedDirectoriesChanges = 'watched-directories-changes',
|
||||
HomeServerStarted = 'home-server-started',
|
||||
}
|
||||
|
||||
export enum MessageToMainProcess {
|
||||
|
||||
Reference in New Issue
Block a user