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:
Mo
2023-07-03 08:30:48 -05:00
committed by GitHub
parent d79e7b14b1
commit 96f42643a9
367 changed files with 5895 additions and 570 deletions

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -0,0 +1,6 @@
export enum FileErrorCodes {
FileDoesNotExist = 'ENOENT',
FileAlreadyExists = 'EEXIST',
OperationNotPermitted = 'EPERM',
DeviceIsBusy = 'EBUSY',
}

View 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
}
}
}
}

View File

@@ -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>
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
import { HomeServerEnvironmentConfiguration } from '@standardnotes/snjs'
export interface HomeServerConfigurationFile {
version: '1.0.0'
info: Record<string, string>
configuration: HomeServerEnvironmentConfiguration
}

View File

@@ -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
}
}

View File

@@ -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(
/**

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -11,6 +11,8 @@ export enum StoreKeys {
UseNativeKeychain = 'useNativeKeychain',
LastRunVersion = 'LastRunVersion',
HomeServerDataLocation = 'HomeServerDataLocation',
LegacyTextBackupsLocation = 'backupsLocation',
LegacyTextBackupsDisabled = 'backupsDisabled',

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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,
}
}