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:
4
packages/desktop/.gitignore
vendored
4
packages/desktop/.gitignore
vendored
@@ -9,5 +9,7 @@ test/data/tmp/
|
||||
.idea
|
||||
.env
|
||||
|
||||
data/*
|
||||
|
||||
codeqldb
|
||||
yarn-error.log
|
||||
yarn-error.log
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"selfReferences": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@standardnotes/home-server": "^1.11.14",
|
||||
"keytar": "^7.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ module.exports = function ({ onlyTranspileTypescript = false, experimentalFeatur
|
||||
module: moduleConfig,
|
||||
externals: {
|
||||
keytar: 'commonjs keytar',
|
||||
"@standardnotes/home-server": "commonjs @standardnotes/home-server",
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "^2.0.9",
|
||||
"@standardnotes/domain-core": "^1.18.0",
|
||||
"@standardnotes/electron-clear-data": "1.1.1",
|
||||
"@standardnotes/web": "workspace:*",
|
||||
"axios": "^1.1.3",
|
||||
@@ -44,7 +45,7 @@
|
||||
"electron": "22.1.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-updater": "^5.3.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"mobx": "^6.7.0"
|
||||
@@ -52,6 +53,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "*",
|
||||
"@babel/preset-env": "*",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/lodash": "^4.14.189",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "18",
|
||||
@@ -107,7 +109,8 @@
|
||||
"NSCameraUsageDescription": "Standard Notes requires access to your camera to enable the Moments feature."
|
||||
},
|
||||
"asarUnpack": [
|
||||
"node_modules/keytar"
|
||||
"node_modules/keytar",
|
||||
"node_modules/@standardnotes/home-server"
|
||||
],
|
||||
"target": [
|
||||
"dmg",
|
||||
|
||||
@@ -17,21 +17,8 @@ export interface FileBackupsDevice
|
||||
LegacyBackupsMethods,
|
||||
PlaintextBackupsMethods,
|
||||
TextBackupsMethods {
|
||||
openLocation(path: string): Promise<void>
|
||||
|
||||
joinPaths(...paths: string[]): Promise<string>
|
||||
|
||||
/**
|
||||
* The reason we combine presenting a directory picker and transfering old files to the new location
|
||||
* in one function is so we don't have to expose a general `transferDirectories` function to the web app,
|
||||
* which would give it too much power.
|
||||
* @param appendPath The path to append to the selected directory.
|
||||
*/
|
||||
presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
appendPath: string,
|
||||
oldLocation?: string,
|
||||
): Promise<string | undefined>
|
||||
|
||||
monitorPlaintextBackupsLocationForChanges(backupsDirectory: string): Promise<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface DirectoryManagerInterface {
|
||||
/**
|
||||
* The reason we combine presenting a directory picker and transfering old files to the new location
|
||||
* in one function is so we don't have to expose a general `transferDirectories` function to the web app,
|
||||
* which would give it too much power.
|
||||
*
|
||||
* @param appendPath The path to append to the selected directory.
|
||||
*/
|
||||
presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
appendPath: string,
|
||||
oldLocation?: string,
|
||||
): Promise<string | undefined>
|
||||
|
||||
openLocation(path: string): Promise<void>
|
||||
|
||||
getDirectoryManagerLastErrorMessage(): Promise<string | undefined>
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export * from './Device/FileBackupMetadataFile'
|
||||
export * from './Device/FileBackupsConstantsV1'
|
||||
export * from './Device/FileBackupsDevice'
|
||||
export * from './Device/FileBackupsMapping'
|
||||
export * from './Directory/DirectoryManagerInterface'
|
||||
export * from './Operations/DownloadAndDecrypt'
|
||||
export * from './Operations/EncryptAndUpload'
|
||||
export * from './Service/BackupServiceInterface'
|
||||
|
||||
@@ -3,13 +3,16 @@ import {
|
||||
AppleIAPProductId,
|
||||
AppleIAPReceipt,
|
||||
ApplicationIdentifier,
|
||||
ApplicationInterface,
|
||||
DatabaseKeysLoadChunkResponse,
|
||||
DatabaseLoadOptions,
|
||||
Environment,
|
||||
MobileDeviceInterface,
|
||||
namespacedKey,
|
||||
NamespacedRootKeyInKeychain,
|
||||
Platform as SNPlatform,
|
||||
RawKeychainValue,
|
||||
RawStorageKey,
|
||||
removeFromArray,
|
||||
TransferPayload,
|
||||
UuidString,
|
||||
@@ -72,6 +75,19 @@ export class MobileDevice implements MobileDeviceInterface {
|
||||
private colorSchemeService?: ColorSchemeObserverService,
|
||||
) {}
|
||||
|
||||
async removeRawStorageValuesForIdentifier(identifier: string): Promise<void> {
|
||||
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.SnjsVersion))
|
||||
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.StorageObject))
|
||||
}
|
||||
|
||||
setApplication(_application: ApplicationInterface): void {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
removeApplication(_application: ApplicationInterface): void {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
async authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null> {
|
||||
const { Fido2ApiModule } = NativeModules
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.2.3",
|
||||
"@types/node": "^20.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "*",
|
||||
"@typescript-eslint/parser": "*",
|
||||
"eslint": "^8.27.0",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { DeinitMode } from './DeinitMode'
|
||||
import { DeinitSource } from './DeinitSource'
|
||||
import { UserClientInterface } from '../User/UserClientInterface'
|
||||
import { SessionsClientInterface } from '../Session/SessionsClientInterface'
|
||||
import { HomeServerServiceInterface } from '../HomeServer/HomeServerServiceInterface'
|
||||
import { User } from '@standardnotes/responses'
|
||||
|
||||
export interface ApplicationInterface {
|
||||
@@ -61,6 +62,9 @@ export interface ApplicationInterface {
|
||||
|
||||
getUser(): User | undefined
|
||||
hasAccount(): boolean
|
||||
setCustomHost(host: string): Promise<void>
|
||||
isThirdPartyHostUsed(): boolean
|
||||
isUsingHomeServer(): Promise<boolean>
|
||||
|
||||
importData(data: BackupFile, awaitSync?: boolean): Promise<ImportDataReturnType>
|
||||
/**
|
||||
@@ -94,6 +98,7 @@ export interface ApplicationInterface {
|
||||
get subscriptions(): SubscriptionClientInterface
|
||||
get fileBackups(): BackupServiceInterface | undefined
|
||||
get sessions(): SessionsClientInterface
|
||||
get homeServer(): HomeServerServiceInterface | undefined
|
||||
get vaults(): VaultServiceInterface
|
||||
get challenges(): ChallengeServiceInterface
|
||||
get alerts(): AlertService
|
||||
|
||||
@@ -11,7 +11,7 @@ import { InternalEventBusInterface } from '..'
|
||||
import { AlertService } from '../Alert/AlertService'
|
||||
import { ApiServiceInterface } from '../Api/ApiServiceInterface'
|
||||
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
|
||||
import { FileBackupsDevice } from '@standardnotes/files'
|
||||
import { DirectoryManagerInterface, FileBackupsDevice } from '@standardnotes/files'
|
||||
|
||||
describe('backup service', () => {
|
||||
let apiService: ApiServiceInterface
|
||||
@@ -23,7 +23,7 @@ describe('backup service', () => {
|
||||
let encryptor: EncryptionProviderInterface
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
let backupService: FilesBackupService
|
||||
let device: FileBackupsDevice
|
||||
let device: FileBackupsDevice & DirectoryManagerInterface
|
||||
let session: SessionsClientInterface
|
||||
let storage: StorageServiceInterface
|
||||
let payloads: PayloadManagerInterface
|
||||
@@ -42,7 +42,7 @@ describe('backup service', () => {
|
||||
|
||||
status = {} as jest.Mocked<StatusServiceInterface>
|
||||
|
||||
device = {} as jest.Mocked<FileBackupsDevice>
|
||||
device = {} as jest.Mocked<FileBackupsDevice & DirectoryManagerInterface>
|
||||
device.getFileBackupReadToken = jest.fn()
|
||||
device.readNextChunk = jest.fn()
|
||||
device.joinPaths = jest.fn()
|
||||
@@ -80,6 +80,7 @@ describe('backup service', () => {
|
||||
session,
|
||||
payloads,
|
||||
history,
|
||||
device,
|
||||
internalEventBus,
|
||||
)
|
||||
backupService.getFilesBackupsLocation = jest.fn().mockReturnValue('/')
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
BackupServiceInterface,
|
||||
DesktopWatchedDirectoriesChanges,
|
||||
SuperConverterServiceInterface,
|
||||
DirectoryManagerInterface,
|
||||
} from '@standardnotes/files'
|
||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
||||
@@ -59,6 +60,7 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
private session: SessionsClientInterface,
|
||||
private payloads: PayloadManagerInterface,
|
||||
private history: HistoryServiceInterface,
|
||||
private directory: DirectoryManagerInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
@@ -161,15 +163,15 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
const textBackupsLocation = this.getTextBackupsLocation()
|
||||
|
||||
if (fileBackupsLocation) {
|
||||
void this.device.openLocation(fileBackupsLocation)
|
||||
void this.directory.openLocation(fileBackupsLocation)
|
||||
}
|
||||
|
||||
if (plaintextBackupsLocation) {
|
||||
void this.device.openLocation(plaintextBackupsLocation)
|
||||
void this.directory.openLocation(plaintextBackupsLocation)
|
||||
}
|
||||
|
||||
if (textBackupsLocation) {
|
||||
void this.device.openLocation(textBackupsLocation)
|
||||
void this.directory.openLocation(textBackupsLocation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +196,7 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
async enableTextBackups(): Promise<void> {
|
||||
let location = this.getTextBackupsLocation()
|
||||
if (!location) {
|
||||
location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
await this.prependWorkspacePathForPath(TextBackupsDirectoryName),
|
||||
)
|
||||
if (!location) {
|
||||
@@ -217,13 +219,13 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
async openTextBackupsLocation(): Promise<void> {
|
||||
const location = this.getTextBackupsLocation()
|
||||
if (location) {
|
||||
void this.device.openLocation(location)
|
||||
void this.directory.openLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
async changeTextBackupsLocation(): Promise<string | undefined> {
|
||||
const oldLocation = this.getTextBackupsLocation()
|
||||
const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
await this.prependWorkspacePathForPath(TextBackupsDirectoryName),
|
||||
oldLocation,
|
||||
)
|
||||
@@ -253,7 +255,7 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
public async enablePlaintextBackups(): Promise<void> {
|
||||
let location = this.getPlaintextBackupsLocation()
|
||||
if (!location) {
|
||||
location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
await this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName),
|
||||
)
|
||||
if (!location) {
|
||||
@@ -279,13 +281,13 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
async openPlaintextBackupsLocation(): Promise<void> {
|
||||
const location = this.getPlaintextBackupsLocation()
|
||||
if (location) {
|
||||
void this.device.openLocation(location)
|
||||
void this.directory.openLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
async changePlaintextBackupsLocation(): Promise<string | undefined> {
|
||||
const oldLocation = this.getPlaintextBackupsLocation()
|
||||
const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
await this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName),
|
||||
oldLocation,
|
||||
)
|
||||
@@ -302,7 +304,7 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
public async enableFilesBackups(): Promise<void> {
|
||||
let location = this.getFilesBackupsLocation()
|
||||
if (!location) {
|
||||
location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
await this.prependWorkspacePathForPath(FileBackupsDirectoryName),
|
||||
)
|
||||
if (!location) {
|
||||
@@ -328,7 +330,7 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
|
||||
public async changeFilesBackupsLocation(): Promise<string | undefined> {
|
||||
const oldLocation = this.getFilesBackupsLocation()
|
||||
const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
await this.prependWorkspacePathForPath(FileBackupsDirectoryName),
|
||||
oldLocation,
|
||||
)
|
||||
@@ -344,7 +346,7 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
public async openFilesBackupsLocation(): Promise<void> {
|
||||
const location = this.getFilesBackupsLocation()
|
||||
if (location) {
|
||||
void this.device.openLocation(location)
|
||||
void this.directory.openLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +391,7 @@ export class FilesBackupService extends AbstractService implements BackupService
|
||||
|
||||
public async openFileBackup(record: FileBackupRecord): Promise<void> {
|
||||
const location = await this.getFileBackupAbsolutePath(record)
|
||||
await this.device.openLocation(location)
|
||||
await this.directory.openLocation(location)
|
||||
}
|
||||
|
||||
private async handleChangedFiles(files: FileItem[]): Promise<void> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChallengeInterface } from './ChallengeInterface'
|
||||
import { ChallengePrompt } from './Prompt/ChallengePrompt'
|
||||
import { ChallengeReason } from './Types/ChallengeReason'
|
||||
import { ChallengeValidation } from './Types/ChallengeValidation'
|
||||
import { ChallengeValue } from './Types/ChallengeValue'
|
||||
|
||||
/**
|
||||
* A challenge is a stateless description of what the client needs to provide
|
||||
@@ -11,6 +12,7 @@ import { ChallengeValidation } from './Types/ChallengeValidation'
|
||||
*/
|
||||
export class Challenge implements ChallengeInterface {
|
||||
public readonly id = Math.random()
|
||||
customHandler?: (challenge: ChallengeInterface, values: ChallengeValue[]) => Promise<void>
|
||||
|
||||
constructor(
|
||||
public readonly prompts: ChallengePrompt[],
|
||||
@@ -18,9 +20,7 @@ export class Challenge implements ChallengeInterface {
|
||||
public readonly cancelable: boolean,
|
||||
public readonly _heading?: string,
|
||||
public readonly _subheading?: string,
|
||||
) {
|
||||
Object.freeze(this)
|
||||
}
|
||||
) {}
|
||||
|
||||
/** Outside of the modal, this is the title of the modal itself */
|
||||
get modalTitle(): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
|
||||
import { ChallengeReason } from './Types/ChallengeReason'
|
||||
import { ChallengeValidation } from './Types/ChallengeValidation'
|
||||
import { ChallengeValue } from './Types/ChallengeValue'
|
||||
|
||||
export interface ChallengeInterface {
|
||||
readonly id: number
|
||||
@@ -8,6 +9,8 @@ export interface ChallengeInterface {
|
||||
readonly reason: ChallengeReason
|
||||
readonly cancelable: boolean
|
||||
|
||||
customHandler?: (challenge: ChallengeInterface, values: ChallengeValue[]) => Promise<void>
|
||||
|
||||
/** Outside of the modal, this is the title of the modal itself */
|
||||
get modalTitle(): string
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Environment } from '@standardnotes/models'
|
||||
|
||||
import { HomeServerManagerInterface } from '../HomeServer/HomeServerManagerInterface'
|
||||
|
||||
import { WebClientRequiresDesktopMethods } from './DesktopWebCommunication'
|
||||
import { DeviceInterface } from './DeviceInterface'
|
||||
import { WebOrDesktopDeviceInterface } from './WebOrDesktopDeviceInterface'
|
||||
@@ -10,6 +12,9 @@ export function isDesktopDevice(x: DeviceInterface): x is DesktopDeviceInterface
|
||||
return x.environment === Environment.Desktop
|
||||
}
|
||||
|
||||
export interface DesktopDeviceInterface extends WebOrDesktopDeviceInterface, WebClientRequiresDesktopMethods {
|
||||
export interface DesktopDeviceInterface
|
||||
extends WebOrDesktopDeviceInterface,
|
||||
WebClientRequiresDesktopMethods,
|
||||
HomeServerManagerInterface {
|
||||
environment: Environment.Desktop
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DecryptedTransferPayload } from '@standardnotes/models'
|
||||
import { DesktopWatchedDirectoriesChanges, FileBackupsDevice } from '@standardnotes/files'
|
||||
import { DesktopWatchedDirectoriesChanges, DirectoryManagerInterface, FileBackupsDevice } from '@standardnotes/files'
|
||||
|
||||
export interface WebClientRequiresDesktopMethods extends FileBackupsDevice {
|
||||
export interface WebClientRequiresDesktopMethods extends FileBackupsDevice, DirectoryManagerInterface {
|
||||
syncComponents(payloads: unknown[]): void
|
||||
|
||||
onSearch(text?: string): void
|
||||
@@ -21,4 +21,6 @@ export interface DesktopClientRequiresWebMethods {
|
||||
onComponentInstallationComplete(componentData: DecryptedTransferPayload, error: unknown): Promise<void>
|
||||
|
||||
handleWatchedDirectoriesChanges(changes: DesktopWatchedDirectoriesChanges): Promise<void>
|
||||
|
||||
handleHomeServerStarted(serverUrl: string): Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApplicationInterface } from './../Application/ApplicationInterface'
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import {
|
||||
FullyFormedTransferPayload,
|
||||
@@ -31,6 +32,11 @@ export interface DeviceInterface {
|
||||
|
||||
removeAllRawStorageValues(): Promise<void>
|
||||
|
||||
removeRawStorageValuesForIdentifier(identifier: ApplicationIdentifier): Promise<void>
|
||||
|
||||
setApplication(application: ApplicationInterface): void
|
||||
removeApplication(application: ApplicationInterface): void
|
||||
|
||||
/**
|
||||
* On web platforms, databased created may be new.
|
||||
* New databases can be because of new sessions, or if the browser deleted it.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface HomeServerEnvironmentConfiguration {
|
||||
jwtSecret: string
|
||||
authJwtSecret: string
|
||||
encryptionServerKey: string
|
||||
pseudoKeyParamsKey: string
|
||||
valetTokenSecret: string
|
||||
port: number
|
||||
logLevel?: string
|
||||
databaseEngine: 'sqlite' | 'mysql'
|
||||
mysqlConfiguration?: {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
database: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface HomeServerManagerInterface {
|
||||
startHomeServer(): Promise<string | undefined>
|
||||
setHomeServerConfiguration(configurationJSONString: string): Promise<void>
|
||||
getHomeServerConfiguration(): Promise<string | undefined>
|
||||
setHomeServerDataLocation(location: string): Promise<void>
|
||||
stopHomeServer(): Promise<string | undefined>
|
||||
activatePremiumFeatures(username: string): Promise<string | undefined>
|
||||
getHomeServerLogs(): Promise<string[]>
|
||||
isHomeServerRunning(): Promise<boolean>
|
||||
getHomeServerUrl(): Promise<string | undefined>
|
||||
getHomeServerLastErrorMessage(): Promise<string | undefined>
|
||||
}
|
||||
171
packages/services/src/Domain/HomeServer/HomeServerService.ts
Normal file
171
packages/services/src/Domain/HomeServer/HomeServerService.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Result } from '@standardnotes/domain-core'
|
||||
|
||||
import { ApplicationStage } from '../Application/ApplicationStage'
|
||||
import { DesktopDeviceInterface } from '../Device/DesktopDeviceInterface'
|
||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
import { RawStorageKey } from '../Storage/StorageKeys'
|
||||
|
||||
import { HomeServerServiceInterface } from './HomeServerServiceInterface'
|
||||
import { HomeServerEnvironmentConfiguration } from './HomeServerEnvironmentConfiguration'
|
||||
import { HomeServerStatus } from './HomeServerStatus'
|
||||
|
||||
export class HomeServerService extends AbstractService implements HomeServerServiceInterface {
|
||||
private readonly HOME_SERVER_DATA_DIRECTORY_NAME = '.homeserver'
|
||||
|
||||
constructor(
|
||||
private desktopDevice: DesktopDeviceInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
;(this.desktopDevice as unknown) = undefined
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
override async handleApplicationStage(stage: ApplicationStage) {
|
||||
await super.handleApplicationStage(stage)
|
||||
|
||||
switch (stage) {
|
||||
case ApplicationStage.StorageDecrypted_09: {
|
||||
await this.setHomeServerDataLocationOnDevice()
|
||||
break
|
||||
}
|
||||
case ApplicationStage.Launched_10: {
|
||||
await this.startHomeServerIfItIsEnabled()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeServerStatus(): Promise<HomeServerStatus> {
|
||||
const isHomeServerRunning = await this.desktopDevice.isHomeServerRunning()
|
||||
|
||||
if (!isHomeServerRunning) {
|
||||
return { status: 'off', errorMessage: await this.desktopDevice.getHomeServerLastErrorMessage() }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'on',
|
||||
url: await this.getHomeServerUrl(),
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeServerLogs(): Promise<string[]> {
|
||||
return this.desktopDevice.getHomeServerLogs()
|
||||
}
|
||||
|
||||
async getHomeServerUrl(): Promise<string | undefined> {
|
||||
return this.desktopDevice.getHomeServerUrl()
|
||||
}
|
||||
|
||||
async startHomeServer(): Promise<string | undefined> {
|
||||
return this.desktopDevice.startHomeServer()
|
||||
}
|
||||
|
||||
async stopHomeServer(): Promise<string | undefined> {
|
||||
return this.desktopDevice.stopHomeServer()
|
||||
}
|
||||
|
||||
async isHomeServerRunning(): Promise<boolean> {
|
||||
return this.desktopDevice.isHomeServerRunning()
|
||||
}
|
||||
|
||||
async activatePremiumFeatures(username: string): Promise<Result<string>> {
|
||||
const result = await this.desktopDevice.activatePremiumFeatures(username)
|
||||
|
||||
if (result !== undefined) {
|
||||
return Result.fail(result)
|
||||
}
|
||||
|
||||
return Result.ok('Premium features activated')
|
||||
}
|
||||
|
||||
async setHomeServerConfiguration(config: HomeServerEnvironmentConfiguration): Promise<void> {
|
||||
await this.desktopDevice.setHomeServerConfiguration(JSON.stringify(config))
|
||||
}
|
||||
|
||||
async getHomeServerConfiguration(): Promise<HomeServerEnvironmentConfiguration | undefined> {
|
||||
const configurationJSONString = await this.desktopDevice.getHomeServerConfiguration()
|
||||
if (!configurationJSONString) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return JSON.parse(configurationJSONString) as HomeServerEnvironmentConfiguration
|
||||
}
|
||||
|
||||
async enableHomeServer(): Promise<void> {
|
||||
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerEnabled, 'true')
|
||||
|
||||
await this.startHomeServer()
|
||||
}
|
||||
|
||||
async isHomeServerEnabled(): Promise<boolean> {
|
||||
const value = await this.desktopDevice.getRawStorageValue(RawStorageKey.HomeServerEnabled)
|
||||
|
||||
return value === 'true'
|
||||
}
|
||||
|
||||
async getHomeServerDataLocation(): Promise<string | undefined> {
|
||||
return this.desktopDevice.getRawStorageValue(RawStorageKey.HomeServerDataLocation)
|
||||
}
|
||||
|
||||
async disableHomeServer(): Promise<Result<string>> {
|
||||
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerEnabled, 'false')
|
||||
|
||||
const result = await this.stopHomeServer()
|
||||
if (result !== undefined) {
|
||||
return Result.fail(result)
|
||||
}
|
||||
|
||||
return Result.ok('Home server disabled')
|
||||
}
|
||||
|
||||
async changeHomeServerDataLocation(): Promise<Result<string>> {
|
||||
const oldLocation = await this.getHomeServerDataLocation()
|
||||
const newLocation = await this.desktopDevice.presentDirectoryPickerForLocationChangeAndTransferOld(
|
||||
this.HOME_SERVER_DATA_DIRECTORY_NAME,
|
||||
oldLocation,
|
||||
)
|
||||
|
||||
if (!newLocation) {
|
||||
const lastErrorMessage = await this.desktopDevice.getDirectoryManagerLastErrorMessage()
|
||||
|
||||
return Result.fail(lastErrorMessage ?? 'No location selected')
|
||||
}
|
||||
|
||||
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerDataLocation, newLocation)
|
||||
|
||||
await this.desktopDevice.setHomeServerDataLocation(newLocation)
|
||||
|
||||
return Result.ok(newLocation)
|
||||
}
|
||||
|
||||
async openHomeServerDataLocation(): Promise<void> {
|
||||
const location = await this.getHomeServerDataLocation()
|
||||
if (location) {
|
||||
void this.desktopDevice.openLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
private async startHomeServerIfItIsEnabled(): Promise<void> {
|
||||
const homeServerIsEnabled = await this.isHomeServerEnabled()
|
||||
if (homeServerIsEnabled) {
|
||||
await this.startHomeServer()
|
||||
}
|
||||
}
|
||||
|
||||
private async setHomeServerDataLocationOnDevice(): Promise<void> {
|
||||
let location = await this.getHomeServerDataLocation()
|
||||
if (!location) {
|
||||
const documentsDirectory = await this.desktopDevice.getUserDocumentsDirectory()
|
||||
location = `${documentsDirectory}/${this.HOME_SERVER_DATA_DIRECTORY_NAME}`
|
||||
}
|
||||
|
||||
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerDataLocation, location)
|
||||
|
||||
await this.desktopDevice.setHomeServerDataLocation(location)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Result } from '@standardnotes/domain-core'
|
||||
|
||||
import { HomeServerEnvironmentConfiguration } from './HomeServerEnvironmentConfiguration'
|
||||
import { HomeServerStatus } from './HomeServerStatus'
|
||||
|
||||
export interface HomeServerServiceInterface {
|
||||
activatePremiumFeatures(username: string): Promise<Result<string>>
|
||||
isHomeServerRunning(): Promise<boolean>
|
||||
isHomeServerEnabled(): Promise<boolean>
|
||||
getHomeServerDataLocation(): Promise<string | undefined>
|
||||
enableHomeServer(): Promise<void>
|
||||
disableHomeServer(): Promise<Result<string>>
|
||||
startHomeServer(): Promise<string | undefined>
|
||||
stopHomeServer(): Promise<string | undefined>
|
||||
changeHomeServerDataLocation(): Promise<Result<string>>
|
||||
openHomeServerDataLocation(): Promise<void>
|
||||
getHomeServerConfiguration(): Promise<HomeServerEnvironmentConfiguration | undefined>
|
||||
setHomeServerConfiguration(config: HomeServerEnvironmentConfiguration): Promise<void>
|
||||
getHomeServerUrl(): Promise<string | undefined>
|
||||
getHomeServerStatus(): Promise<HomeServerStatus>
|
||||
getHomeServerLogs(): Promise<string[]>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type HomeServerStatus = {
|
||||
status: 'on' | 'off'
|
||||
url?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { log, removeFromArray } from '@standardnotes/utils'
|
||||
import { EventObserver } from '../Event/EventObserver'
|
||||
import { ServiceInterface } from './ServiceInterface'
|
||||
import { ApplicationServiceInterface } from './ApplicationServiceInterface'
|
||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||
import { ApplicationStage } from '../Application/ApplicationStage'
|
||||
import { InternalEventPublishStrategy } from '../Internal/InternalEventPublishStrategy'
|
||||
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
|
||||
|
||||
export abstract class AbstractService<EventName = string, EventData = unknown>
|
||||
implements ServiceInterface<EventName, EventData>
|
||||
implements ApplicationServiceInterface<EventName, EventData>
|
||||
{
|
||||
private eventObservers: EventObserver<EventName, EventData>[] = []
|
||||
public loggingEnabled = false
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApplicationStage } from '../Application/ApplicationStage'
|
||||
import { ServiceDiagnostics } from '../Diagnostics/ServiceDiagnostics'
|
||||
import { EventObserver } from '../Event/EventObserver'
|
||||
|
||||
export interface ServiceInterface<E, D> extends ServiceDiagnostics {
|
||||
export interface ApplicationServiceInterface<E, D> extends ServiceDiagnostics {
|
||||
loggingEnabled: boolean
|
||||
addEventObserver(observer: EventObserver<E, D>): () => void
|
||||
blockDeinit(): Promise<void>
|
||||
@@ -12,6 +12,7 @@ export interface SessionsClientInterface {
|
||||
populateSessionFromDemoShareToken(token: Base64String): Promise<void>
|
||||
|
||||
getUser(): User | undefined
|
||||
isSignedIn(): boolean
|
||||
get userUuid(): string
|
||||
getSureUser(): User
|
||||
|
||||
@@ -24,7 +25,6 @@ export interface SessionsClientInterface {
|
||||
ephemeral: boolean,
|
||||
minAllowedVersion?: ProtocolVersion,
|
||||
): Promise<SessionManagerResponse>
|
||||
isSignedIn(): boolean
|
||||
bypassChecksAndSignInWithRootKey(
|
||||
email: string,
|
||||
rootKey: RootKeyInterface,
|
||||
|
||||
@@ -6,6 +6,8 @@ export enum RawStorageKey {
|
||||
StorageObject = 'storage',
|
||||
DescriptorRecord = 'descriptors',
|
||||
SnjsVersion = 'snjs_version',
|
||||
HomeServerEnabled = 'home_server_enabled',
|
||||
HomeServerDataLocation = 'home_serve_data_location',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,9 @@ export * from './Device/DeviceInterface'
|
||||
export * from './Device/MobileDeviceInterface'
|
||||
export * from './Device/TypeCheck'
|
||||
export * from './Device/WebOrDesktopDeviceInterface'
|
||||
|
||||
export * from './Device/DatabaseLoadOptions'
|
||||
export * from './Device/DatabaseItemMetadata'
|
||||
export * from './Device/DatabaseLoadSorter'
|
||||
export * from './Diagnostics/ServiceDiagnostics'
|
||||
|
||||
export * from './Encryption/DecryptBackupFileUseCase'
|
||||
@@ -77,7 +79,11 @@ export * from './Feature/SetOfflineFeaturesFunctionResponse'
|
||||
export * from './Files/FileService'
|
||||
|
||||
export * from './History/HistoryServiceInterface'
|
||||
|
||||
export * from './HomeServer/HomeServerEnvironmentConfiguration'
|
||||
export * from './HomeServer/HomeServerManagerInterface'
|
||||
export * from './HomeServer/HomeServerService'
|
||||
export * from './HomeServer/HomeServerServiceInterface'
|
||||
export * from './HomeServer/HomeServerStatus'
|
||||
export * from './Integrity/IntegrityApiInterface'
|
||||
export * from './Integrity/IntegrityEvent'
|
||||
export * from './Integrity/IntegrityEventPayload'
|
||||
@@ -114,8 +120,7 @@ export * from './Revision/RevisionClientInterface'
|
||||
export * from './Revision/RevisionManager'
|
||||
|
||||
export * from './Service/AbstractService'
|
||||
export * from './Service/ServiceInterface'
|
||||
|
||||
export * from './Service/ApplicationServiceInterface'
|
||||
export * from './Session/SessionManagerResponse'
|
||||
export * from './Session/SessionsClientInterface'
|
||||
export * from './Session/SessionEvent'
|
||||
|
||||
@@ -70,7 +70,12 @@ import {
|
||||
RevisionManager,
|
||||
ApiServiceEvent,
|
||||
} from '@standardnotes/services'
|
||||
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
|
||||
import {
|
||||
BackupServiceInterface,
|
||||
DirectoryManagerInterface,
|
||||
FileBackupsDevice,
|
||||
FilesClientInterface,
|
||||
} from '@standardnotes/files'
|
||||
import { ComputePrivateUsername } from '@standardnotes/encryption'
|
||||
import { useBoolean } from '@standardnotes/utils'
|
||||
import {
|
||||
@@ -133,14 +138,14 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
*/
|
||||
private deprecatedHttpService!: InternalServices.DeprecatedHttpService
|
||||
private declare httpService: HttpServiceInterface
|
||||
private payloadManager!: InternalServices.PayloadManager
|
||||
public payloadManager!: InternalServices.PayloadManager
|
||||
public protocolService!: EncryptionService
|
||||
private diskStorageService!: InternalServices.DiskStorageService
|
||||
private inMemoryStore!: ExternalServices.KeyValueStoreInterface<string>
|
||||
/**
|
||||
* @deprecated will be fully replaced by @standardnotes/api services
|
||||
*/
|
||||
private apiService!: InternalServices.SNApiService
|
||||
public apiService!: InternalServices.SNApiService
|
||||
private declare userApiService: UserApiServiceInterface
|
||||
private declare userServer: UserServerInterface
|
||||
private declare userRequestServer: UserRequestServerInterface
|
||||
@@ -152,7 +157,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
|
||||
private sessionManager!: InternalServices.SNSessionManager
|
||||
private syncService!: InternalServices.SNSyncService
|
||||
private challengeService!: InternalServices.ChallengeService
|
||||
public challengeService!: InternalServices.ChallengeService
|
||||
public singletonManager!: InternalServices.SNSingletonManager
|
||||
public componentManagerService!: InternalServices.SNComponentManager
|
||||
public protectionService!: InternalServices.SNProtectionService
|
||||
@@ -184,6 +189,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private declare authenticatorManager: AuthenticatorClientInterface
|
||||
private declare authManager: AuthClientInterface
|
||||
private declare revisionManager: RevisionClientInterface
|
||||
private homeServerService?: ExternalServices.HomeServerService
|
||||
|
||||
private declare _signInWithRecoveryCodes: SignInWithRecoveryCodes
|
||||
private declare _getRecoveryCodes: GetRecoveryCodes
|
||||
@@ -200,7 +206,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
|
||||
private eventHandlers: ApplicationObserver[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private services: ExternalServices.ServiceInterface<any, any>[] = []
|
||||
private services: ExternalServices.ApplicationServiceInterface<any, any>[] = []
|
||||
private streamRemovers: ObserverRemover[] = []
|
||||
private serviceObservers: ObserverRemover[] = []
|
||||
private managedSubscribers: ObserverRemover[] = []
|
||||
@@ -381,6 +387,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return this.challengeService
|
||||
}
|
||||
|
||||
get homeServer(): ExternalServices.HomeServerServiceInterface | undefined {
|
||||
return this.homeServerService
|
||||
}
|
||||
|
||||
public get vaults(): ExternalServices.VaultServiceInterface {
|
||||
return this.vaultService
|
||||
}
|
||||
@@ -712,7 +722,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
await this.apiService.setHost(host)
|
||||
}
|
||||
|
||||
public getHost(): string | undefined {
|
||||
public getHost(): string {
|
||||
return this.apiService.getHost()
|
||||
}
|
||||
|
||||
@@ -1221,6 +1231,14 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return this.apiService.isThirdPartyHostUsed()
|
||||
}
|
||||
|
||||
async isUsingHomeServer(): Promise<boolean> {
|
||||
if (!this.homeServerService) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.getHost() === (await this.homeServerService.getHomeServerUrl())
|
||||
}
|
||||
|
||||
private constructServices() {
|
||||
this.createMappers()
|
||||
this.createPayloadManager()
|
||||
@@ -1264,6 +1282,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.createStatusService()
|
||||
if (isDesktopDevice(this.deviceInterface)) {
|
||||
this.createFilesBackupService(this.deviceInterface)
|
||||
this.createHomeServerService(this.deviceInterface)
|
||||
}
|
||||
this.createMigrationService()
|
||||
this.createFileService()
|
||||
@@ -1328,6 +1347,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
;(this.authenticatorManager as unknown) = undefined
|
||||
;(this.authManager as unknown) = undefined
|
||||
;(this.revisionManager as unknown) = undefined
|
||||
;(this.homeServerService as unknown) = undefined
|
||||
;(this._signInWithRecoveryCodes as unknown) = undefined
|
||||
;(this._getRecoveryCodes as unknown) = undefined
|
||||
;(this._addAuthenticator as unknown) = undefined
|
||||
@@ -1689,6 +1709,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.services.push(this.diskStorageService)
|
||||
}
|
||||
|
||||
private createHomeServerService(device: ExternalServices.DesktopDeviceInterface) {
|
||||
this.homeServerService = new ExternalServices.HomeServerService(device, this.internalEventBus)
|
||||
|
||||
this.services.push(this.homeServerService)
|
||||
}
|
||||
|
||||
private createInMemoryStorageManager() {
|
||||
this.inMemoryStore = new ExternalServices.InMemoryStore()
|
||||
}
|
||||
@@ -1928,13 +1954,14 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.itemManager,
|
||||
this.apiService,
|
||||
this.protocolService,
|
||||
device,
|
||||
device as FileBackupsDevice,
|
||||
this.statusService,
|
||||
this.options.crypto,
|
||||
this.storage,
|
||||
this.sessions,
|
||||
this.payloadManager,
|
||||
this.historyManager,
|
||||
device as DirectoryManagerInterface,
|
||||
this.internalEventBus,
|
||||
)
|
||||
this.services.push(this.filesBackupService)
|
||||
|
||||
@@ -5,11 +5,13 @@ export const isDev = true
|
||||
export enum LoggingDomain {
|
||||
DatabaseLoad,
|
||||
Sync,
|
||||
AccountMigration,
|
||||
}
|
||||
|
||||
const LoggingStatus: Record<LoggingDomain, boolean> = {
|
||||
[LoggingDomain.DatabaseLoad]: false,
|
||||
[LoggingDomain.Sync]: false,
|
||||
[LoggingDomain.AccountMigration]: true,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -254,6 +254,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic
|
||||
|
||||
private deleteChallengeOperation(operation: ChallengeOperation) {
|
||||
const challenge = operation.challenge
|
||||
challenge.customHandler = undefined
|
||||
operation.deinit()
|
||||
|
||||
delete this.challengeOperations[challenge.id]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SettingData } from '@standardnotes/responses'
|
||||
import {
|
||||
MuteSignInEmailsOption,
|
||||
MuteFailedCloudBackupsEmailsOption,
|
||||
MuteFailedBackupsEmailsOption,
|
||||
EmailBackupFrequency,
|
||||
ListedAuthorSecretsData,
|
||||
@@ -15,7 +14,6 @@ type SettingType =
|
||||
| ListedAuthorSecretsData
|
||||
| LogSessionUserAgentOption
|
||||
| MuteFailedBackupsEmailsOption
|
||||
| MuteFailedCloudBackupsEmailsOption
|
||||
| MuteSignInEmailsOption
|
||||
| MuteMarketingEmailsOption
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ export class DiskStorageService extends Services.AbstractService implements Serv
|
||||
this.persistencePolicy = persistencePolicy
|
||||
|
||||
if (this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral) {
|
||||
await this.deviceInterface.removeAllRawStorageValues()
|
||||
await this.deviceInterface.clearNamespacedKeychainValue(this.identifier)
|
||||
await this.deviceInterface.removeAllDatabaseEntries(this.identifier)
|
||||
await this.deviceInterface.removeRawStorageValuesForIdentifier(this.identifier)
|
||||
await this.clearAllPayloads()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { SyncOpStatus } from './SyncOpStatus'
|
||||
import { SyncOptions } from '@standardnotes/services'
|
||||
import { AbstractService, SyncEvent, SyncOptions } from '@standardnotes/services'
|
||||
|
||||
export interface SyncClientInterface {
|
||||
export interface SyncClientInterface extends AbstractService<SyncEvent> {
|
||||
setLaunchPriorityUuids(launchPriorityUuids: string[]): void
|
||||
|
||||
sync(options?: Partial<SyncOptions>): Promise<unknown>
|
||||
|
||||
isOutOfSync(): boolean
|
||||
|
||||
getLastSyncDate(): Date | undefined
|
||||
|
||||
getSyncStatus(): SyncOpStatus
|
||||
lockSyncing(): void
|
||||
unlockSyncing(): void
|
||||
|
||||
completedOnlineDownloadFirstSync: boolean
|
||||
}
|
||||
|
||||
@@ -172,6 +172,11 @@ export default class WebDeviceInterface {
|
||||
localStorage.removeItem(KEYCHAIN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
async removeRawStorageValuesForIdentifier(identifier) {
|
||||
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.SnjsVersion))
|
||||
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.StorageObject))
|
||||
}
|
||||
|
||||
performSoftReset() {}
|
||||
|
||||
performHardReset() {}
|
||||
|
||||
@@ -89,7 +89,10 @@ describe('storage manager', function () {
|
||||
const key = 'foo'
|
||||
const value = 'bar'
|
||||
await this.application.diskStorageService.setValueAndAwaitPersist(key, value)
|
||||
expect(Object.keys(localStorage).length).to.equal(0)
|
||||
|
||||
const expectedKeys = ['keychain']
|
||||
expect(Object.keys(localStorage).length).to.equal(expectedKeys.length)
|
||||
|
||||
const retrievedValue = await this.application.diskStorageService.getValue(key)
|
||||
expect(retrievedValue).to.equal(value)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApplicationEvent, ServiceInterface } from '@standardnotes/services'
|
||||
import { ApplicationEvent, ApplicationServiceInterface } from '@standardnotes/services'
|
||||
|
||||
export interface AbstractUIServiceInterface<EventName = string, EventData = unknown>
|
||||
extends ServiceInterface<EventName, EventData> {
|
||||
extends ApplicationServiceInterface<EventName, EventData> {
|
||||
onAppStart(): Promise<void>
|
||||
onAppEvent(event: ApplicationEvent): Promise<void>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ const PREFERENCE_IDS = [
|
||||
'general',
|
||||
'account',
|
||||
'security',
|
||||
'home-server',
|
||||
'vaults',
|
||||
'appearance',
|
||||
'backups',
|
||||
|
||||
@@ -48,6 +48,13 @@ export class DesktopManager
|
||||
void this.backups.importWatchedDirectoryChanges(changes)
|
||||
}
|
||||
|
||||
async handleHomeServerStarted(serverUrl: string): Promise<void> {
|
||||
const userIsSignedIn = this.application.sessions.isSignedIn()
|
||||
if (!userIsSignedIn) {
|
||||
await this.application.setCustomHost(serverUrl)
|
||||
}
|
||||
}
|
||||
|
||||
beginTextBackupsTimer() {
|
||||
if (this.textBackupsInterval) {
|
||||
clearInterval(this.textBackupsInterval)
|
||||
|
||||
@@ -10,7 +10,11 @@ export {
|
||||
FileBackupReadToken,
|
||||
FileBackupReadChunkResponse,
|
||||
FileDownloadProgress,
|
||||
HomeServerManagerInterface,
|
||||
HomeServerStatus,
|
||||
PlaintextBackupsMapping,
|
||||
DesktopWatchedDirectoriesChanges,
|
||||
DesktopWatchedDirectoriesChange,
|
||||
HomeServerEnvironmentConfiguration,
|
||||
DirectoryManagerInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
GetSortedPayloadsByPriority,
|
||||
DatabaseFullEntryLoadChunk,
|
||||
DatabaseFullEntryLoadChunkResponse,
|
||||
ApplicationInterface,
|
||||
namespacedKey,
|
||||
RawStorageKey,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Database } from '../Database'
|
||||
|
||||
@@ -30,6 +33,12 @@ export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface
|
||||
this.databases.push(database)
|
||||
}
|
||||
|
||||
removeApplication(application: ApplicationInterface): void {
|
||||
const database = this.databaseForIdentifier(application.identifier)
|
||||
database.deinit()
|
||||
this.databases = this.databases.filter((db) => db !== database)
|
||||
}
|
||||
|
||||
public async getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined> {
|
||||
const value = await this.getRawStorageValue(key)
|
||||
if (value == undefined) {
|
||||
@@ -87,6 +96,11 @@ export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface
|
||||
localStorage.clear()
|
||||
}
|
||||
|
||||
async removeRawStorageValuesForIdentifier(identifier: ApplicationIdentifier) {
|
||||
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.SnjsVersion))
|
||||
await this.removeRawStorageValue(namespacedKey(identifier, RawStorageKey.StorageObject))
|
||||
}
|
||||
|
||||
async openDatabase(identifier: ApplicationIdentifier) {
|
||||
this.databaseForIdentifier(identifier).unlock()
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('web application', () => {
|
||||
SNLog.onLog = console.log
|
||||
SNLog.onError = console.error
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const identifier = '123'
|
||||
|
||||
window.matchMedia = jest.fn().mockReturnValue({ matches: false, addListener: jest.fn() })
|
||||
@@ -34,7 +34,7 @@ describe('web application', () => {
|
||||
appVersion: '1.2.3',
|
||||
setApplication: jest.fn(),
|
||||
openDatabase: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
getRawStorageValue: jest.fn().mockImplementation((key) => {
|
||||
getRawStorageValue: jest.fn().mockImplementation(async (key) => {
|
||||
if (key === namespacedKey(identifier, RawStorageKey.SnjsVersion)) {
|
||||
return '10.0.0'
|
||||
}
|
||||
@@ -49,7 +49,7 @@ describe('web application', () => {
|
||||
componentManager.legacyGetDefaultEditor = jest.fn()
|
||||
Object.defineProperty(application, 'componentManager', { value: componentManager })
|
||||
|
||||
application.prepareForLaunch({ receiveChallenge: jest.fn() })
|
||||
await application.prepareForLaunch({ receiveChallenge: jest.fn() })
|
||||
})
|
||||
|
||||
describe('geDefaultEditorIdentifier', () => {
|
||||
|
||||
@@ -90,8 +90,12 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
deviceInterface.environment === Environment.Mobile ? 250 : ApplicationOptionsDefaults.sleepBetweenBatches,
|
||||
allowMultipleSelection: deviceInterface.environment !== Environment.Mobile,
|
||||
allowNoteSelectionStatePersistence: deviceInterface.environment !== Environment.Mobile,
|
||||
u2fAuthenticatorRegistrationPromptFunction: startRegistration,
|
||||
u2fAuthenticatorVerificationPromptFunction: startAuthentication,
|
||||
u2fAuthenticatorRegistrationPromptFunction: startRegistration as unknown as (
|
||||
registrationOptions: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>,
|
||||
u2fAuthenticatorVerificationPromptFunction: startAuthentication as unknown as (
|
||||
authenticationOptions: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>,
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
|
||||
@@ -106,7 +106,11 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
||||
*/
|
||||
setTimeout(() => {
|
||||
if (valuesToProcess.length > 0) {
|
||||
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
|
||||
if (challenge.customHandler) {
|
||||
void challenge.customHandler(challenge, valuesToProcess)
|
||||
} else {
|
||||
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
|
||||
}
|
||||
} else {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
|
||||
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
|
||||
import EditingDisabledBanner from './EditingDisabledBanner'
|
||||
import { reloadFont } from './FontFunctions'
|
||||
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
|
||||
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
|
||||
import { NoteViewProps } from './NoteViewProps'
|
||||
import {
|
||||
@@ -45,6 +44,7 @@ import { SuperEditorContentId } from '../SuperEditor/Constants'
|
||||
import { NoteViewController } from './Controller/NoteViewController'
|
||||
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
|
||||
import { EditorMargins, EditorMaxWidths } from '../EditorWidthSelectionModal/EditorWidths'
|
||||
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
|
||||
import CollaborationInfoHUD from './CollaborationInfoHUD'
|
||||
import Button from '../Button/Button'
|
||||
import ModalOverlay from '../Modal/ModalOverlay'
|
||||
|
||||
@@ -10,6 +10,7 @@ import Listed from './Panes/Listed/Listed'
|
||||
import HelpAndFeedback from './Panes/HelpFeedback'
|
||||
import { PreferencesProps } from './PreferencesProps'
|
||||
import WhatsNew from './Panes/WhatsNew/WhatsNew'
|
||||
import HomeServer from './Panes/HomeServer/HomeServer'
|
||||
import Vaults from './Panes/Vaults/Vaults'
|
||||
|
||||
const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = ({
|
||||
@@ -32,6 +33,8 @@ const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu
|
||||
return <AccountPreferences application={application} viewControllerManager={viewControllerManager} />
|
||||
case 'appearance':
|
||||
return <Appearance application={application} />
|
||||
case 'home-server':
|
||||
return <HomeServer />
|
||||
case 'security':
|
||||
return (
|
||||
<Security
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import Authentication from './Authentication'
|
||||
@@ -17,25 +18,29 @@ type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
}
|
||||
|
||||
const AccountPreferences = ({ application, viewControllerManager }: Props) => (
|
||||
<PreferencesPane>
|
||||
{!application.hasAccount() ? (
|
||||
<Authentication application={application} viewControllerManager={viewControllerManager} />
|
||||
) : (
|
||||
<>
|
||||
<Credentials application={application} viewControllerManager={viewControllerManager} />
|
||||
<Sync application={application} />
|
||||
</>
|
||||
)}
|
||||
<Subscription application={application} viewControllerManager={viewControllerManager} />
|
||||
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
|
||||
{application.hasAccount() && viewControllerManager.featuresController.entitledToFiles && (
|
||||
<FilesSection application={application} />
|
||||
)}
|
||||
{application.hasAccount() && <Email application={application} />}
|
||||
<SignOutWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<DeleteAccount application={application} viewControllerManager={viewControllerManager} />
|
||||
</PreferencesPane>
|
||||
)
|
||||
const AccountPreferences = ({ application, viewControllerManager }: Props) => {
|
||||
const isUsingThirdPartyServer = application.isThirdPartyHostUsed()
|
||||
|
||||
return (
|
||||
<PreferencesPane>
|
||||
{!application.hasAccount() ? (
|
||||
<Authentication application={application} viewControllerManager={viewControllerManager} />
|
||||
) : (
|
||||
<>
|
||||
<Credentials application={application} viewControllerManager={viewControllerManager} />
|
||||
<Sync application={application} />
|
||||
</>
|
||||
)}
|
||||
<Subscription application={application} viewControllerManager={viewControllerManager} />
|
||||
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
|
||||
{application.hasAccount() && viewControllerManager.featuresController.entitledToFiles && (
|
||||
<FilesSection application={application} />
|
||||
)}
|
||||
{application.hasAccount() && !isUsingThirdPartyServer && <Email application={application} />}
|
||||
<SignOutWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<DeleteAccount application={application} viewControllerManager={viewControllerManager} />
|
||||
</PreferencesPane>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(AccountPreferences)
|
||||
|
||||
@@ -21,15 +21,18 @@ const FilesSection: FunctionComponent<Props> = ({ application }) => {
|
||||
const filesQuotaUsed = await application.settings.getSubscriptionSetting(
|
||||
SettingName.create(SettingName.NAMES.FileUploadBytesUsed).getValue(),
|
||||
)
|
||||
const filesQuotaTotal = await application.settings.getSubscriptionSetting(
|
||||
SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(),
|
||||
)
|
||||
|
||||
if (filesQuotaUsed) {
|
||||
setFilesQuotaUsed(parseFloat(filesQuotaUsed))
|
||||
}
|
||||
if (filesQuotaTotal) {
|
||||
setFilesQuotaTotal(parseFloat(filesQuotaTotal))
|
||||
|
||||
if (!application.isThirdPartyHostUsed()) {
|
||||
const filesQuotaTotal = await application.settings.getSubscriptionSetting(
|
||||
SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(),
|
||||
)
|
||||
|
||||
if (filesQuotaTotal) {
|
||||
setFilesQuotaTotal(parseFloat(filesQuotaTotal))
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
@@ -51,7 +54,7 @@ const FilesSection: FunctionComponent<Props> = ({ application }) => {
|
||||
<>
|
||||
<div className="mt-1 mb-1">
|
||||
<span className="font-semibold">{formatSizeToReadableString(filesQuotaUsed)}</span> of{' '}
|
||||
<span>{formatSizeToReadableString(filesQuotaTotal)}</span> used
|
||||
<span>{application.isThirdPartyHostUsed() ? '∞' : formatSizeToReadableString(filesQuotaTotal)}</span> used
|
||||
</div>
|
||||
<progress
|
||||
className="progress-bar w-full"
|
||||
|
||||
@@ -15,13 +15,15 @@ type Props = {
|
||||
}
|
||||
|
||||
const Backups: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
|
||||
const isUsingThirdPartyServer = application.isThirdPartyHostUsed()
|
||||
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<DataBackups application={application} viewControllerManager={viewControllerManager} />
|
||||
<TextBackupsCrossPlatform application={application} />
|
||||
<PlaintextBackupsCrossPlatform />
|
||||
<FileBackupsCrossPlatform application={application} />
|
||||
<EmailBackups application={application} />
|
||||
{!isUsingThirdPartyServer && <EmailBackups application={application} />}
|
||||
</PreferencesPane>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const OfflineSubscription: FunctionComponent<Props> = ({ application }) => {
|
||||
const OfflineSubscription: FunctionComponent<Props> = ({ application, onSuccess }) => {
|
||||
const [activationCode, setActivationCode] = useState('')
|
||||
const [isSuccessfullyActivated, setIsSuccessfullyActivated] = useState(false)
|
||||
const [isSuccessfullyRemoved, setIsSuccessfullyRemoved] = useState(false)
|
||||
@@ -33,14 +34,44 @@ const OfflineSubscription: FunctionComponent<Props> = ({ application }) => {
|
||||
const handleSubscriptionCodeSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const homeServer = application.homeServer
|
||||
|
||||
const homeServerEnabled = homeServer && homeServer.isHomeServerEnabled()
|
||||
const homeServerIsRunning = homeServerEnabled && (await homeServer.isHomeServerRunning())
|
||||
|
||||
if (homeServerEnabled) {
|
||||
if (!homeServerIsRunning) {
|
||||
await application.alertService.alert('Please start your home server before activating offline features.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const signedInUser = application.getUser()
|
||||
if (!signedInUser) {
|
||||
return
|
||||
}
|
||||
|
||||
const serverActivationResult = await homeServer.activatePremiumFeatures(signedInUser.email)
|
||||
if (serverActivationResult.isFailed()) {
|
||||
await application.alertService.alert(serverActivationResult.getError())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const result = await application.features.setOfflineFeaturesCode(activationCode)
|
||||
|
||||
if (result instanceof ClientDisplayableError) {
|
||||
await application.alertService.alert(result.text)
|
||||
} else {
|
||||
setIsSuccessfullyActivated(true)
|
||||
setHasUserPreviouslyStoredCode(true)
|
||||
setIsSuccessfullyRemoved(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsSuccessfullyActivated(true)
|
||||
setHasUserPreviouslyStoredCode(true)
|
||||
setIsSuccessfullyRemoved(false)
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
|
||||
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
||||
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
||||
import HomeServerSettings from './HomeServerSettings'
|
||||
|
||||
const HomeServer = () => {
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<HomeServerSettings />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
|
||||
<PreferencesGroup>
|
||||
<Title>Remote access</Title>
|
||||
<Subtitle>Accessing your home server while on the go is easy and secure with Tailscale.</Subtitle>
|
||||
<ol className="mt-3 ml-3 list-outside list-decimal">
|
||||
<li>
|
||||
Register on{' '}
|
||||
<a className="text-info" href="https://tailscale.com/">
|
||||
Tailscale.com
|
||||
</a>{' '}
|
||||
for free.
|
||||
</li>
|
||||
<li className="mt-2">
|
||||
Download Tailscale on this computer and complete the Tailscale setup wizard until you are presented with the
|
||||
IP address of your computer. It should start with something like 100.xxx...
|
||||
</li>
|
||||
<li className="mt-2">Download Tailscale on your mobile device and sign into your Tailscale account.</li>
|
||||
<li className="mt-2">Activate the Tailscale VPN on your mobile device.</li>
|
||||
<li className="mt-2">
|
||||
Open Standard Notes on your mobile device and sign into your home server by specifying the sync server URL
|
||||
during sign in. The URL will be the Tailscale-based IP address of this computer, followed by the port number
|
||||
of your home server. For example, if your computer Tailscale IP address is 100.112.45.106 and your home
|
||||
server is running on port 3127, your sync server URL will be http://100.112.45.106:3127.
|
||||
</li>
|
||||
</ol>
|
||||
</PreferencesGroup>
|
||||
|
||||
<PreferencesGroup>
|
||||
<Title>Backing up your data</Title>
|
||||
<Subtitle>
|
||||
For automatic backups, you can place your server's data inside of a synced cloud folder, like Dropbox,
|
||||
Tresorit, or iCloud Drive.
|
||||
</Subtitle>
|
||||
<ol className="mt-3 ml-3 list-outside list-decimal">
|
||||
<li>Change your server's data location by selecting "Change Location" in the Home Server section above.</li>
|
||||
<li className="mt-2">Select a cloud drive to store your server's data in.</li>
|
||||
<li className="mt-2">Restart your home server.</li>
|
||||
</ol>
|
||||
<Text className="mt-3">
|
||||
Your Standard Notes data is always end-to-end encrypted on disk, so your cloud provider will not be able to
|
||||
read your notes or files.
|
||||
</Text>
|
||||
</PreferencesGroup>
|
||||
</PreferencesPane>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeServer
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import { Pill, Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import EncryptionStatusItem from '../Security/EncryptionStatusItem'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import OfflineSubscription from '../General/Advanced/OfflineSubscription'
|
||||
import EnvironmentConfiguration from './Settings/EnvironmentConfiguration'
|
||||
import DatabaseConfiguration from './Settings/DatabaseConfiguration'
|
||||
import { HomeServerEnvironmentConfiguration, HomeServerServiceInterface, classNames, sleep } from '@standardnotes/snjs'
|
||||
import StatusIndicator from './Status/StatusIndicator'
|
||||
import { Status } from './Status/Status'
|
||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import AccordionItem from '@/Components/Shared/AccordionItem'
|
||||
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
|
||||
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
|
||||
|
||||
const HomeServerSettings = () => {
|
||||
const SERVER_SYNTHEIC_CHANGE_DELAY = 1500
|
||||
const LOGS_REFRESH_INTERVAL = 5000
|
||||
|
||||
const application = useApplication()
|
||||
const homeServerService = application.homeServer as HomeServerServiceInterface
|
||||
const featuresService = application.features
|
||||
const sessionsService = application.sessions
|
||||
const viewControllerManager = application.getViewControllerManager()
|
||||
|
||||
const logsTextarea = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [status, setStatus] = useState<Status>()
|
||||
const [homeServerDataLocation, setHomeServerDataLocation] = useState('')
|
||||
const [isAPremiumUser, setIsAPremiumUser] = useState(false)
|
||||
const [isSignedIn, setIsSignedIn] = useState(false)
|
||||
const [showOfflineSubscriptionActivation, setShowOfflineSubscriptionActivation] = useState(false)
|
||||
const [logsIntervalRef, setLogsIntervalRef] = useState<NodeJS.Timer | null>(null)
|
||||
const [homeServerConfiguration, setHomeServerConfiguration] = useState<HomeServerEnvironmentConfiguration | null>(
|
||||
null,
|
||||
)
|
||||
const [homeServerEnabled, setHomeServerEnabled] = useState(false)
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
const result = await homeServerService.getHomeServerStatus()
|
||||
setStatus({
|
||||
state: result.status === 'on' ? 'online' : result.errorMessage ? 'error' : 'offline',
|
||||
message: result.status === 'on' ? 'Online' : result.errorMessage ? 'Offline' : 'Starting...',
|
||||
description:
|
||||
result.status === 'on' ? (
|
||||
<>
|
||||
Accessible on local network at{' '}
|
||||
<a href={result.url} className="font-bold text-info" target="_blank">
|
||||
{result.url}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
result.errorMessage ?? 'Your home server is offline.'
|
||||
),
|
||||
})
|
||||
}, [homeServerService, setStatus])
|
||||
|
||||
const initialyLoadHomeServerConfiguration = useCallback(async () => {
|
||||
if (!homeServerConfiguration) {
|
||||
const homeServerConfiguration = await homeServerService.getHomeServerConfiguration()
|
||||
if (homeServerConfiguration) {
|
||||
setHomeServerConfiguration(homeServerConfiguration)
|
||||
}
|
||||
}
|
||||
}, [homeServerConfiguration, homeServerService])
|
||||
|
||||
const toggleHomeServer = useCallback(async () => {
|
||||
if (status?.state === 'restarting') {
|
||||
return
|
||||
}
|
||||
|
||||
if (homeServerEnabled) {
|
||||
setStatus({ state: 'restarting', message: 'Shutting down...' })
|
||||
|
||||
const result = await homeServerService.disableHomeServer()
|
||||
|
||||
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
|
||||
|
||||
if (result.isFailed() && (await homeServerService.isHomeServerRunning())) {
|
||||
setStatus({ state: 'error', message: result.getError() })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setHomeServerEnabled(await homeServerService.isHomeServerEnabled())
|
||||
|
||||
await refreshStatus()
|
||||
} else {
|
||||
setStatus({ state: 'restarting', message: 'Starting...' })
|
||||
|
||||
await homeServerService.enableHomeServer()
|
||||
|
||||
setHomeServerEnabled(await homeServerService.isHomeServerEnabled())
|
||||
|
||||
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
|
||||
|
||||
await refreshStatus()
|
||||
|
||||
void initialyLoadHomeServerConfiguration()
|
||||
}
|
||||
}, [homeServerEnabled, homeServerService, status, refreshStatus, initialyLoadHomeServerConfiguration])
|
||||
|
||||
const clearLogs = useCallback(
|
||||
(hideLogs = false) => {
|
||||
if (logsIntervalRef !== null) {
|
||||
clearInterval(logsIntervalRef)
|
||||
}
|
||||
if (hideLogs) {
|
||||
setShowLogs(false)
|
||||
}
|
||||
setLogs([])
|
||||
},
|
||||
[setLogs, logsIntervalRef],
|
||||
)
|
||||
|
||||
const setupLogsRefresh = useCallback(async () => {
|
||||
clearLogs()
|
||||
|
||||
setLogs(await homeServerService.getHomeServerLogs())
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
setLogs(await homeServerService.getHomeServerLogs())
|
||||
}, LOGS_REFRESH_INTERVAL)
|
||||
setLogsIntervalRef(interval)
|
||||
}, [homeServerService, clearLogs])
|
||||
|
||||
useEffect(() => {
|
||||
async function updateHomeServerDataLocation() {
|
||||
const location = await homeServerService.getHomeServerDataLocation()
|
||||
if (location) {
|
||||
setHomeServerDataLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
void updateHomeServerDataLocation()
|
||||
|
||||
async function updateHomeServerEnabled() {
|
||||
setHomeServerEnabled(await homeServerService.isHomeServerEnabled())
|
||||
}
|
||||
|
||||
void updateHomeServerEnabled()
|
||||
|
||||
setIsAPremiumUser(featuresService.hasOfflineRepo())
|
||||
|
||||
setIsSignedIn(sessionsService.isSignedIn())
|
||||
|
||||
void initialyLoadHomeServerConfiguration()
|
||||
|
||||
void refreshStatus()
|
||||
}, [featuresService, sessionsService, homeServerService, refreshStatus, initialyLoadHomeServerConfiguration])
|
||||
|
||||
const handleHomeServerConfigurationChange = useCallback(
|
||||
async (changedServerConfiguration: HomeServerEnvironmentConfiguration) => {
|
||||
try {
|
||||
setStatus({ state: 'restarting', message: 'Applying changes and restarting...' })
|
||||
|
||||
setHomeServerConfiguration(changedServerConfiguration)
|
||||
|
||||
await homeServerService.stopHomeServer()
|
||||
|
||||
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
|
||||
|
||||
await homeServerService.setHomeServerConfiguration(changedServerConfiguration)
|
||||
|
||||
clearLogs(true)
|
||||
|
||||
const result = await homeServerService.startHomeServer()
|
||||
if (result !== undefined) {
|
||||
setStatus({ state: 'error', message: result })
|
||||
}
|
||||
|
||||
void refreshStatus()
|
||||
} catch (error) {
|
||||
setStatus({ state: 'error', message: (error as Error).message })
|
||||
}
|
||||
},
|
||||
[homeServerService, setStatus, setHomeServerConfiguration, refreshStatus, clearLogs],
|
||||
)
|
||||
|
||||
const changeHomeServerDataLocation = useCallback(
|
||||
async (location?: string) => {
|
||||
try {
|
||||
await homeServerService.stopHomeServer()
|
||||
|
||||
if (location === undefined) {
|
||||
const oldLocation = await homeServerService.getHomeServerDataLocation()
|
||||
const newLocationOrError = await homeServerService.changeHomeServerDataLocation()
|
||||
if (newLocationOrError.isFailed()) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message: `${newLocationOrError.getError()}. Restoring to initial location in a moment...`,
|
||||
})
|
||||
|
||||
await sleep(2 * SERVER_SYNTHEIC_CHANGE_DELAY)
|
||||
|
||||
await changeHomeServerDataLocation(oldLocation)
|
||||
|
||||
return
|
||||
}
|
||||
location = newLocationOrError.getValue()
|
||||
}
|
||||
|
||||
setStatus({ state: 'restarting', message: 'Applying changes and restarting...' })
|
||||
|
||||
await sleep(SERVER_SYNTHEIC_CHANGE_DELAY)
|
||||
|
||||
setHomeServerDataLocation(location)
|
||||
|
||||
clearLogs(true)
|
||||
|
||||
const result = await homeServerService.startHomeServer()
|
||||
if (result !== undefined) {
|
||||
setStatus({ state: 'error', message: result })
|
||||
}
|
||||
|
||||
void refreshStatus()
|
||||
} catch (error) {
|
||||
setStatus({ state: 'error', message: (error as Error).message })
|
||||
}
|
||||
},
|
||||
[homeServerService, setStatus, setHomeServerDataLocation, refreshStatus, clearLogs],
|
||||
)
|
||||
|
||||
const openHomeServerDataLocation = useCallback(async () => {
|
||||
try {
|
||||
await homeServerService.openHomeServerDataLocation()
|
||||
} catch (error) {
|
||||
setStatus({ state: 'error', message: (error as Error).message })
|
||||
}
|
||||
}, [homeServerService])
|
||||
|
||||
const handleShowLogs = () => {
|
||||
const newValueForShowingLogs = !showLogs
|
||||
|
||||
if (newValueForShowingLogs) {
|
||||
void setupLogsRefresh()
|
||||
} else {
|
||||
if (logsIntervalRef) {
|
||||
clearInterval(logsIntervalRef)
|
||||
setLogsIntervalRef(null)
|
||||
}
|
||||
}
|
||||
|
||||
setShowLogs(newValueForShowingLogs)
|
||||
}
|
||||
|
||||
function isTextareaScrolledToBottom(textarea: HTMLTextAreaElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = textarea
|
||||
const scrolledToBottom = scrollTop + clientHeight >= scrollHeight
|
||||
return scrolledToBottom
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (logsTextarea.current) {
|
||||
setIsAtBottom(isTextareaScrolledToBottom(logsTextarea.current))
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (logsTextarea.current) {
|
||||
setIsAtBottom(isTextareaScrolledToBottom(logsTextarea.current))
|
||||
}
|
||||
}
|
||||
|
||||
const textArea = logsTextarea.current
|
||||
|
||||
if (textArea) {
|
||||
textArea.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (textArea) {
|
||||
textArea.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
if (logsIntervalRef !== null) {
|
||||
clearInterval(logsIntervalRef)
|
||||
}
|
||||
}
|
||||
}, [logsIntervalRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (logsTextarea.current && isAtBottom) {
|
||||
logsTextarea.current.scrollTop = logsTextarea.current.scrollHeight
|
||||
}
|
||||
}, [logs, isAtBottom])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start">
|
||||
<Title>Home Server</Title>
|
||||
<Pill style={'success'}>Labs</Pill>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-10 flex flex-col">
|
||||
<Subtitle>Sync your data on a private cloud running on your home computer.</Subtitle>
|
||||
</div>
|
||||
<Switch disabled={status?.state === 'restarting'} onChange={toggleHomeServer} checked={homeServerEnabled} />
|
||||
</div>
|
||||
{homeServerEnabled && (
|
||||
<div>
|
||||
<StatusIndicator className={'mr-3'} status={status} homeServerService={homeServerService} />
|
||||
|
||||
{status?.state !== 'restarting' && (
|
||||
<>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<>
|
||||
<Text className="mb-3">Home server is enabled. All data is stored at:</Text>
|
||||
|
||||
<EncryptionStatusItem
|
||||
status={homeServerDataLocation || 'Not Set'}
|
||||
icon={<Icon type="attachment-file" className="min-h-5 min-w-5" />}
|
||||
checkmark={false}
|
||||
/>
|
||||
|
||||
<div className="mt-2.5 flex flex-row">
|
||||
<Button label="Open Location" className={'mr-3 text-xs'} onClick={openHomeServerDataLocation} />
|
||||
<Button
|
||||
label="Change Location"
|
||||
className={'mr-3 text-xs'}
|
||||
onClick={() => changeHomeServerDataLocation()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<AccordionItem title={'Logs'} onClick={handleShowLogs}>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex max-w-full flex-grow flex-col">
|
||||
<textarea
|
||||
ref={logsTextarea}
|
||||
disabled={true}
|
||||
className="h-[500px] overflow-y-auto whitespace-pre-wrap bg-contrast p-2"
|
||||
value={logs.join('\n')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
{homeServerConfiguration && (
|
||||
<>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<DatabaseConfiguration
|
||||
homeServerConfiguration={homeServerConfiguration}
|
||||
setHomeServerConfigurationChangedCallback={handleHomeServerConfigurationChange}
|
||||
/>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<EnvironmentConfiguration
|
||||
homeServerConfiguration={homeServerConfiguration}
|
||||
setHomeServerConfigurationChangedCallback={handleHomeServerConfigurationChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSignedIn && !isAPremiumUser && (
|
||||
<>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<div className={'mt-2 grid grid-cols-1 rounded-md border border-border p-4'}>
|
||||
<div className="flex items-center">
|
||||
<Icon
|
||||
className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)}
|
||||
type={PremiumFeatureIconName}
|
||||
/>
|
||||
<h1 className="sk-h3 m-0 text-sm font-semibold">Activate Premium Features</h1>
|
||||
</div>
|
||||
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
|
||||
Enter your purchased offline subscription code to activate all the features offered by the home
|
||||
server.
|
||||
</p>
|
||||
<Button
|
||||
primary
|
||||
small
|
||||
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
|
||||
onClick={() => {
|
||||
setShowOfflineSubscriptionActivation(!showOfflineSubscriptionActivation)
|
||||
}}
|
||||
>
|
||||
{showOfflineSubscriptionActivation ? 'Close' : 'Activate Premium Features'}
|
||||
</Button>
|
||||
|
||||
{showOfflineSubscriptionActivation && (
|
||||
<OfflineSubscription
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
onSuccess={() => {
|
||||
setIsAPremiumUser(true)
|
||||
setShowOfflineSubscriptionActivation(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeServerSettings
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { HomeServerEnvironmentConfiguration } from '@standardnotes/snjs'
|
||||
|
||||
import AccordionItem from '@/Components/Shared/AccordionItem'
|
||||
import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup'
|
||||
import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import { Subtitle } from '../../../PreferencesComponents/Content'
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import RadioButtonGroup from '@/Components/RadioButtonGroup/RadioButtonGroup'
|
||||
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
|
||||
|
||||
type Props = {
|
||||
homeServerConfiguration: HomeServerEnvironmentConfiguration
|
||||
setHomeServerConfigurationChangedCallback: (homeServerConfiguration: HomeServerEnvironmentConfiguration) => void
|
||||
}
|
||||
|
||||
const DatabaseConfiguration = ({ setHomeServerConfigurationChangedCallback, homeServerConfiguration }: Props) => {
|
||||
const [valuesChanged, setValuesChanged] = useState(false)
|
||||
const [selectedDatabaseEngine, setSelectedDatabaseEngine] = useState<string>(homeServerConfiguration.databaseEngine)
|
||||
const [isMySQLSelected, setIsMySQLSelected] = useState(homeServerConfiguration.databaseEngine === 'mysql')
|
||||
const [mysqlDatabase, setMysqlDatabase] = useState(homeServerConfiguration.mysqlConfiguration?.database || '')
|
||||
const [mysqlHost, setMysqlHost] = useState(homeServerConfiguration.mysqlConfiguration?.host || '')
|
||||
const [mysqlPassword, setMysqlPassword] = useState(homeServerConfiguration.mysqlConfiguration?.password || '')
|
||||
const [mysqlPort, setMysqlPort] = useState(homeServerConfiguration.mysqlConfiguration?.port || 3306)
|
||||
const [mysqlUsername, setMysqlUsername] = useState(homeServerConfiguration.mysqlConfiguration?.username || '')
|
||||
|
||||
useEffect(() => {
|
||||
const databaseEngineChanged = homeServerConfiguration.databaseEngine !== selectedDatabaseEngine
|
||||
|
||||
setIsMySQLSelected(selectedDatabaseEngine === 'mysql')
|
||||
|
||||
let mysqlConfigurationChanged = false
|
||||
if (selectedDatabaseEngine === 'mysql') {
|
||||
const allMysqlInputsFilled = !!mysqlDatabase && !!mysqlHost && !!mysqlPassword && !!mysqlPort && !!mysqlUsername
|
||||
|
||||
mysqlConfigurationChanged =
|
||||
allMysqlInputsFilled &&
|
||||
(homeServerConfiguration.mysqlConfiguration?.username !== mysqlUsername ||
|
||||
homeServerConfiguration.mysqlConfiguration?.password !== mysqlPassword ||
|
||||
homeServerConfiguration.mysqlConfiguration?.host !== mysqlHost ||
|
||||
homeServerConfiguration.mysqlConfiguration?.port !== mysqlPort ||
|
||||
homeServerConfiguration.mysqlConfiguration?.database !== mysqlDatabase)
|
||||
|
||||
setValuesChanged(mysqlConfigurationChanged || databaseEngineChanged)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setValuesChanged(databaseEngineChanged)
|
||||
}, [
|
||||
homeServerConfiguration,
|
||||
selectedDatabaseEngine,
|
||||
mysqlDatabase,
|
||||
mysqlHost,
|
||||
mysqlPassword,
|
||||
mysqlPort,
|
||||
mysqlUsername,
|
||||
])
|
||||
|
||||
const handleConfigurationChange = useCallback(async () => {
|
||||
homeServerConfiguration.databaseEngine = selectedDatabaseEngine as 'sqlite' | 'mysql'
|
||||
if (selectedDatabaseEngine === 'mysql') {
|
||||
homeServerConfiguration.mysqlConfiguration = {
|
||||
username: mysqlUsername,
|
||||
password: mysqlPassword,
|
||||
host: mysqlHost,
|
||||
port: mysqlPort,
|
||||
database: mysqlDatabase,
|
||||
}
|
||||
}
|
||||
|
||||
setHomeServerConfigurationChangedCallback(homeServerConfiguration)
|
||||
|
||||
setValuesChanged(false)
|
||||
}, [
|
||||
homeServerConfiguration,
|
||||
selectedDatabaseEngine,
|
||||
setHomeServerConfigurationChangedCallback,
|
||||
mysqlUsername,
|
||||
mysqlPassword,
|
||||
mysqlHost,
|
||||
mysqlPort,
|
||||
mysqlDatabase,
|
||||
])
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<AccordionItem title={'Database'}>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex max-w-full flex-grow flex-col">
|
||||
<RadioButtonGroup
|
||||
items={[
|
||||
{ label: 'SQLite', value: 'sqlite' },
|
||||
{ label: 'MySQL', value: 'mysql' },
|
||||
]}
|
||||
value={selectedDatabaseEngine}
|
||||
onChange={setSelectedDatabaseEngine}
|
||||
/>
|
||||
{isMySQLSelected && (
|
||||
<>
|
||||
<div className={'mt-2'}>
|
||||
In order to connect to a MySQL database, ensure that your system has MySQL installed. For detailed
|
||||
instructions, visit the{' '}
|
||||
<a className="text-info" href="https://dev.mysql.com/doc/mysql-installation-excerpt/8.0/en/">
|
||||
MySQL website.
|
||||
</a>
|
||||
</div>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Database Username</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'username'}
|
||||
defaultValue={homeServerConfiguration?.mysqlConfiguration?.username}
|
||||
onChange={setMysqlUsername}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Database Password</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'password'}
|
||||
defaultValue={homeServerConfiguration?.mysqlConfiguration?.password}
|
||||
onChange={setMysqlPassword}
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Database Host</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'host'}
|
||||
defaultValue={homeServerConfiguration?.mysqlConfiguration?.host}
|
||||
onChange={setMysqlHost}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Database Port</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'port'}
|
||||
defaultValue={
|
||||
homeServerConfiguration?.mysqlConfiguration?.port
|
||||
? homeServerConfiguration?.mysqlConfiguration?.port.toString()
|
||||
: ''
|
||||
}
|
||||
onChange={(port: string) => setMysqlPort(Number(port))}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Database Name</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'name'}
|
||||
defaultValue={homeServerConfiguration?.mysqlConfiguration?.database}
|
||||
onChange={setMysqlDatabase}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{valuesChanged && (
|
||||
<Button className="mt-3 min-w-20" primary label="Apply & Restart" onClick={handleConfigurationChange} />
|
||||
)}
|
||||
</AccordionItem>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatabaseConfiguration
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import AccordionItem from '@/Components/Shared/AccordionItem'
|
||||
import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup'
|
||||
import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment'
|
||||
import { Subtitle } from '../../../PreferencesComponents/Content'
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import { HomeServerEnvironmentConfiguration } from '@standardnotes/snjs'
|
||||
import Dropdown from '@/Components/Dropdown/Dropdown'
|
||||
|
||||
type Props = {
|
||||
homeServerConfiguration: HomeServerEnvironmentConfiguration
|
||||
setHomeServerConfigurationChangedCallback: (homeServerConfiguration: HomeServerEnvironmentConfiguration) => void
|
||||
}
|
||||
|
||||
const EnvironmentConfiguration = ({ setHomeServerConfigurationChangedCallback, homeServerConfiguration }: Props) => {
|
||||
const [authJWT, setAuthJWT] = useState(homeServerConfiguration.authJwtSecret)
|
||||
const [jwt, setJWT] = useState(homeServerConfiguration.jwtSecret)
|
||||
const [pseudoParamsKey, setPseudoParamsKey] = useState(homeServerConfiguration.pseudoKeyParamsKey)
|
||||
const [valetTokenSecret, setValetTokenSecret] = useState(homeServerConfiguration.valetTokenSecret)
|
||||
const [port, setPort] = useState(homeServerConfiguration.port)
|
||||
|
||||
const [valuesChanged, setValuesChanged] = useState(false)
|
||||
const [selectedLogLevel, setSelectedLogLevel] = useState(homeServerConfiguration.logLevel as string)
|
||||
|
||||
useEffect(() => {
|
||||
const anyOfTheValuesHaveChanged =
|
||||
homeServerConfiguration.authJwtSecret !== authJWT ||
|
||||
homeServerConfiguration.jwtSecret !== jwt ||
|
||||
homeServerConfiguration.pseudoKeyParamsKey !== pseudoParamsKey ||
|
||||
homeServerConfiguration.valetTokenSecret !== valetTokenSecret ||
|
||||
homeServerConfiguration.port !== port ||
|
||||
homeServerConfiguration.logLevel !== selectedLogLevel
|
||||
|
||||
setValuesChanged(anyOfTheValuesHaveChanged)
|
||||
}, [
|
||||
homeServerConfiguration,
|
||||
selectedLogLevel,
|
||||
authJWT,
|
||||
jwt,
|
||||
pseudoParamsKey,
|
||||
valetTokenSecret,
|
||||
port,
|
||||
setValuesChanged,
|
||||
])
|
||||
|
||||
const handleConfigurationChange = useCallback(async () => {
|
||||
homeServerConfiguration.authJwtSecret = authJWT
|
||||
homeServerConfiguration.jwtSecret = jwt
|
||||
homeServerConfiguration.pseudoKeyParamsKey = pseudoParamsKey
|
||||
homeServerConfiguration.valetTokenSecret = valetTokenSecret
|
||||
homeServerConfiguration.port = port
|
||||
homeServerConfiguration.logLevel = selectedLogLevel ?? homeServerConfiguration.logLevel
|
||||
|
||||
setHomeServerConfigurationChangedCallback(homeServerConfiguration)
|
||||
|
||||
setValuesChanged(false)
|
||||
}, [
|
||||
setHomeServerConfigurationChangedCallback,
|
||||
homeServerConfiguration,
|
||||
selectedLogLevel,
|
||||
authJWT,
|
||||
jwt,
|
||||
pseudoParamsKey,
|
||||
valetTokenSecret,
|
||||
port,
|
||||
])
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<AccordionItem title={'Advanced settings'}>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex max-w-full flex-grow flex-col">
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Auth JWT Secret</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'Auth JWT Secret'}
|
||||
defaultValue={homeServerConfiguration?.authJwtSecret}
|
||||
onChange={setAuthJWT}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>JWT Secret</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'JWT Secret'}
|
||||
defaultValue={homeServerConfiguration?.jwtSecret}
|
||||
onChange={setJWT}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Encryption Server Key</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'Encryption Server Key'}
|
||||
defaultValue={homeServerConfiguration?.encryptionServerKey}
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Pseudo Params Key</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'Pseudo Params Key'}
|
||||
defaultValue={homeServerConfiguration?.pseudoKeyParamsKey}
|
||||
onChange={setPseudoParamsKey}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Valet Token Secret</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'Valet Token Secret'}
|
||||
defaultValue={homeServerConfiguration?.valetTokenSecret}
|
||||
onChange={setValetTokenSecret}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Port</Subtitle>
|
||||
<div className="text-xs">Changing the port will require you to sign out of all existing sessions.</div>
|
||||
<div className={'mt-2'}>
|
||||
<DecoratedInput
|
||||
placeholder={'Port'}
|
||||
defaultValue={homeServerConfiguration?.port.toString()}
|
||||
onChange={(port: string) => setPort(Number(port))}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<Subtitle className={'mt-2'}>Log Level</Subtitle>
|
||||
<div className={'mt-2'}>
|
||||
<Dropdown
|
||||
label="Log level"
|
||||
items={[
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Warning', value: 'warn' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
]}
|
||||
value={selectedLogLevel}
|
||||
onChange={setSelectedLogLevel}
|
||||
/>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
</div>
|
||||
</div>
|
||||
{valuesChanged && (
|
||||
<Button className="mt-3 min-w-20" primary label="Apply & Restart" onClick={handleConfigurationChange} />
|
||||
)}
|
||||
</AccordionItem>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvironmentConfiguration
|
||||
@@ -0,0 +1,5 @@
|
||||
export type Status = {
|
||||
state: 'restarting' | 'online' | 'error' | 'offline'
|
||||
message: string
|
||||
description?: string | JSX.Element
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { Status } from './Status'
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import { HomeServerServiceInterface } from '@standardnotes/snjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
status: Status | undefined
|
||||
homeServerService: HomeServerServiceInterface
|
||||
className?: string
|
||||
}
|
||||
|
||||
const StatusIndicator = ({ status, className, homeServerService }: Props) => {
|
||||
const application = useApplication()
|
||||
const [signInStatusMessage, setSignInStatusMessage] = useState<string>('')
|
||||
const [signInStatusIcon, setSignInStatusIcon] = useState<string>('')
|
||||
const [signInStatusClassName, setSignInStatusClassName] = useState<string>('')
|
||||
|
||||
let statusClassName: string
|
||||
let icon: string
|
||||
|
||||
switch (status?.state) {
|
||||
case 'online':
|
||||
statusClassName = 'bg-success text-success-contrast'
|
||||
icon = 'check'
|
||||
break
|
||||
case 'error':
|
||||
statusClassName = 'bg-danger text-danger-contrast'
|
||||
icon = 'warning'
|
||||
break
|
||||
default:
|
||||
statusClassName = 'bg-contrast'
|
||||
icon = 'sync'
|
||||
break
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function updateSignedInStatus() {
|
||||
const signedInUser = application.getUser()
|
||||
if (signedInUser) {
|
||||
const isUsingHomeServer = await application.isUsingHomeServer()
|
||||
if (isUsingHomeServer) {
|
||||
setSignInStatusMessage(`You are currently signed into your home server under ${signedInUser.email}`)
|
||||
setSignInStatusClassName('bg-success')
|
||||
setSignInStatusIcon('check')
|
||||
} else {
|
||||
setSignInStatusMessage(
|
||||
`You are not currently signed into your home server. To use your home server, sign out of ${
|
||||
signedInUser.email
|
||||
}, then sign in or register using ${await homeServerService.getHomeServerUrl()}.`,
|
||||
)
|
||||
setSignInStatusClassName('bg-warning')
|
||||
setSignInStatusIcon('warning')
|
||||
}
|
||||
} else {
|
||||
setSignInStatusMessage(
|
||||
`You are not currently signed into your home server. To use your home server, sign in or register using ${await homeServerService.getHomeServerUrl()}`,
|
||||
)
|
||||
setSignInStatusClassName('bg-warning')
|
||||
setSignInStatusIcon('warning')
|
||||
}
|
||||
}
|
||||
|
||||
void updateSignedInStatus()
|
||||
}, [application, homeServerService, setSignInStatusMessage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2.5 flex flex-row items-center">
|
||||
<div className="note-status-tooltip-container relative">
|
||||
<div
|
||||
className={classNames(
|
||||
'peer flex h-5 w-5 items-center justify-center rounded-full',
|
||||
statusClassName,
|
||||
className,
|
||||
)}
|
||||
aria-describedby={ElementIds.NoteStatusTooltip}
|
||||
>
|
||||
<Icon className={status?.state === 'restarting' ? 'animate-spin' : ''} type={icon} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={'mr-3 font-bold'}>{status?.message}</div>
|
||||
<div className={'mr-3'}>{status?.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
{status?.state !== 'restarting' && (
|
||||
<div className="mt-2.5 flex flex-row items-center">
|
||||
<div className="note-status-tooltip-container relative">
|
||||
<div
|
||||
className={classNames(
|
||||
'peer flex h-5 w-5 items-center justify-center rounded-full',
|
||||
signInStatusClassName,
|
||||
className,
|
||||
)}
|
||||
aria-describedby={ElementIds.NoteStatusTooltip}
|
||||
>
|
||||
<Icon type={signInStatusIcon} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={'mr-3'}>{signInStatusMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusIndicator
|
||||
@@ -4,12 +4,14 @@ import { WebApplication } from '@/Application/WebApplication'
|
||||
import { PackageProvider } from './Panes/General/Advanced/Packages/Provider/PackageProvider'
|
||||
import { securityPrefsHasBubble } from './Panes/Security/securityPrefsHasBubble'
|
||||
import { PreferenceId } from '@standardnotes/ui-services'
|
||||
import { isDesktopApplication } from '@/Utils'
|
||||
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
|
||||
|
||||
interface PreferencesMenuItem {
|
||||
readonly id: PreferenceId
|
||||
readonly icon: IconType
|
||||
readonly label: string
|
||||
readonly order: number
|
||||
readonly hasBubble?: boolean
|
||||
}
|
||||
|
||||
@@ -21,28 +23,32 @@ interface SelectableMenuItem extends PreferencesMenuItem {
|
||||
* Items are in order of appearance
|
||||
*/
|
||||
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
{ id: 'whats-new', label: "What's New", icon: 'asterisk' },
|
||||
{ id: 'account', label: 'Account', icon: 'user' },
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'security', label: 'Security', icon: 'security' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
|
||||
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
|
||||
{ id: 'get-free-month', label: 'Get a free month', icon: 'star' },
|
||||
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
|
||||
{ id: 'whats-new', label: "What's New", icon: 'asterisk', order: 0 },
|
||||
{ id: 'account', label: 'Account', icon: 'user', order: 1 },
|
||||
{ id: 'general', label: 'General', icon: 'settings', order: 3 },
|
||||
{ id: 'security', label: 'Security', icon: 'security', order: 4 },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore', order: 5 },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes', order: 6 },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed', order: 7 },
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard', order: 8 },
|
||||
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility', order: 9 },
|
||||
{ id: 'get-free-month', label: 'Get a free month', icon: 'star', order: 10 },
|
||||
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help', order: 11 },
|
||||
]
|
||||
|
||||
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
{ id: 'whats-new', label: "What's New", icon: 'asterisk' },
|
||||
{ id: 'account', label: 'Account', icon: 'user' },
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'security', label: 'Security', icon: 'security' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed' },
|
||||
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
|
||||
{ id: 'whats-new', label: "What's New", icon: 'asterisk', order: 0 },
|
||||
{ id: 'account', label: 'Account', icon: 'user', order: 1 },
|
||||
{ id: 'general', label: 'General', icon: 'settings', order: 3 },
|
||||
{ id: 'security', label: 'Security', icon: 'security', order: 4 },
|
||||
{ id: 'backups', label: 'Backups', icon: 'restore', order: 5 },
|
||||
{ id: 'appearance', label: 'Appearance', icon: 'themes', order: 6 },
|
||||
{ id: 'listed', label: 'Listed', icon: 'listed', order: 7 },
|
||||
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help', order: 11 },
|
||||
]
|
||||
|
||||
const DESKTOP_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
|
||||
{ id: 'home-server', label: 'Home Server', icon: 'server', order: 5 },
|
||||
]
|
||||
|
||||
export class PreferencesMenu {
|
||||
@@ -52,10 +58,18 @@ export class PreferencesMenu {
|
||||
|
||||
constructor(private application: WebApplication, private readonly _enableUnfinishedFeatures: boolean) {
|
||||
if (featureTrunkVaultsEnabled()) {
|
||||
PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' })
|
||||
READY_PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' })
|
||||
PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square', order: 5 })
|
||||
READY_PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square', order: 5 })
|
||||
}
|
||||
|
||||
let menuItems = this._enableUnfinishedFeatures ? PREFERENCES_MENU_ITEMS : READY_PREFERENCES_MENU_ITEMS
|
||||
|
||||
if (isDesktopApplication()) {
|
||||
menuItems = [...menuItems, ...DESKTOP_PREFERENCES_MENU_ITEMS]
|
||||
}
|
||||
|
||||
this._menu = menuItems.sort((a, b) => a.order - b.order)
|
||||
|
||||
this._menu = this._enableUnfinishedFeatures ? PREFERENCES_MENU_ITEMS : READY_PREFERENCES_MENU_ITEMS
|
||||
|
||||
this.loadLatestVersions()
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getPurchaseFlowUrl = async (application: WebApplication): Promise<s
|
||||
const currentUrl = window.location.origin
|
||||
const successUrl = isDesktopApplication() ? 'standardnotes://' : currentUrl
|
||||
|
||||
if (application.noAccount()) {
|
||||
if (application.noAccount() || application.isThirdPartyHostUsed()) {
|
||||
return `${window.purchaseUrl}/offline?&success_url=${successUrl}`
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ type Props = {
|
||||
title: string | JSX.Element
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
onClick?: (expanded: boolean) => void
|
||||
}
|
||||
|
||||
const AccordionItem: FunctionComponent<Props> = ({ title, className = '', children }) => {
|
||||
const AccordionItem: FunctionComponent<Props> = ({ title, className = '', children, onClick }) => {
|
||||
const elementRef = useRef<HTMLDivElement>(null)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
@@ -19,6 +20,9 @@ const AccordionItem: FunctionComponent<Props> = ({ title, className = '', childr
|
||||
className="relative flex cursor-pointer items-center justify-between hover:underline"
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded)
|
||||
if (onClick) {
|
||||
onClick(!isExpanded)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Title>{title}</Title>
|
||||
|
||||
@@ -66,6 +66,7 @@ export class FeaturesController extends AbstractViewController {
|
||||
case ApplicationEvent.FeaturesUpdated:
|
||||
case ApplicationEvent.Launched:
|
||||
case ApplicationEvent.LocalDataLoaded:
|
||||
case ApplicationEvent.UserRolesChanged:
|
||||
runInAction(() => {
|
||||
this.hasFolders = this.isEntitledToFolders()
|
||||
this.hasSmartViews = this.isEntitledToSmartViews()
|
||||
|
||||
Reference in New Issue
Block a user