feat: New one-click Home Server, now in Labs. Launch your own self-hosted server instance with just 1 click from the Preferences window. (#2341)

This commit is contained in:
Mo
2023-07-03 08:30:48 -05:00
committed by GitHub
parent d79e7b14b1
commit 96f42643a9
367 changed files with 5895 additions and 570 deletions

View File

@@ -9,5 +9,7 @@ test/data/tmp/
.idea
.env
data/*
codeqldb
yarn-error.log
yarn-error.log

View File

@@ -0,0 +1,60 @@
import path from 'path'
import { shell } from 'electron'
import { DirectoryManagerInterface } from '@standardnotes/snjs'
import { FilesManagerInterface } from '../File/FilesManagerInterface'
export class DirectoryManager implements DirectoryManagerInterface {
private lastErrorMessage: string | undefined
constructor(private filesManager: FilesManagerInterface) {}
async presentDirectoryPickerForLocationChangeAndTransferOld(
appendPath: string,
oldLocation?: string,
): Promise<string | undefined> {
try {
this.lastErrorMessage = undefined
const selectedDirectory = await this.filesManager.openDirectoryPicker('Select')
if (!selectedDirectory) {
return undefined
}
const newPath = path.join(selectedDirectory, path.normalize(appendPath))
await this.filesManager.ensureDirectoryExists(newPath)
if (oldLocation) {
const result = await this.filesManager.moveDirectory(path.normalize(oldLocation), newPath)
if (result.isFailed()) {
this.lastErrorMessage = result.getError()
return undefined
}
const deletingDirectoryResult = await this.filesManager.deleteDir(path.normalize(oldLocation))
if (deletingDirectoryResult.isFailed()) {
this.lastErrorMessage = deletingDirectoryResult.getError()
return undefined
}
}
return newPath
} catch (error) {
this.lastErrorMessage = (error as Error).message
return undefined
}
}
async openLocation(location: string): Promise<void> {
void shell.openPath(location)
}
async getDirectoryManagerLastErrorMessage(): Promise<string | undefined> {
return this.lastErrorMessage
}
}

View File

@@ -5,8 +5,8 @@ import path from 'path'
import { URL } from 'url'
import { extensions as str } from './Strings'
import { Paths } from './Types/Paths'
import { FileDoesNotExist } from './Utils/FileUtils'
import { app } from 'electron'
import { FileErrorCodes } from './File/FileErrorCodes'
const Protocol = 'http'
@@ -80,7 +80,7 @@ function onRequestError(error: Error | { code: string }, response: ServerRespons
let responseCode: number
let message: string
if ('code' in error && error.code === FileDoesNotExist) {
if ('code' in error && error.code === FileErrorCodes.FileDoesNotExist) {
responseCode = 404
message = str().missingExtension
} else {

View File

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

View File

@@ -0,0 +1,314 @@
import { dialog } from 'electron'
import fs, { PathLike } from 'fs'
import fse from 'fs-extra'
import { debounce } from 'lodash'
import path from 'path'
import yauzl from 'yauzl'
import { Result } from '@standardnotes/domain-core'
import { removeFromArray } from '../Utils/Utils'
import { FileErrorCodes } from './FileErrorCodes'
import { FilesManagerInterface } from './FilesManagerInterface'
export class FilesManager implements FilesManagerInterface {
debouncedJSONDiskWriter(durationMs: number, location: string, data: () => unknown): () => void {
let writingToDisk = false
return debounce(async () => {
if (writingToDisk) {
return
}
writingToDisk = true
try {
await this.writeJSONFile(location, data())
} catch (error) {
console.error(error)
} finally {
writingToDisk = false
}
}, durationMs)
}
async openDirectoryPicker(buttonLabel?: string): Promise<string | undefined> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'],
buttonLabel: buttonLabel,
})
return result.filePaths[0]
}
async readJSONFile<T>(filepath: string): Promise<T | undefined> {
try {
const data = await fs.promises.readFile(filepath, 'utf8')
return JSON.parse(data)
} catch (error) {
return undefined
}
}
readJSONFileSync<T>(filepath: string): T {
const data = fs.readFileSync(filepath, 'utf8')
return JSON.parse(data)
}
async writeJSONFile(filepath: string, data: unknown): Promise<void> {
await this.ensureDirectoryExists(path.dirname(filepath))
await fs.promises.writeFile(filepath, JSON.stringify(data, null, 2), 'utf8')
}
async writeFile(filepath: string, data: string): Promise<void> {
await this.ensureDirectoryExists(path.dirname(filepath))
await fs.promises.writeFile(filepath, data, 'utf8')
}
writeJSONFileSync(filepath: string, data: unknown): void {
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8')
}
async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
const stat = await fs.promises.lstat(dirPath)
if (!stat.isDirectory()) {
throw new Error('Tried to create a directory where a file of the same ' + `name already exists: ${dirPath}`)
}
} catch (error: any) {
if (error.code === FileErrorCodes.FileDoesNotExist) {
/**
* No directory here. Make sure there is a *parent* directory, and then
* create it.
*/
await this.ensureDirectoryExists(path.dirname(dirPath))
/** Now that its parent(s) exist, create the directory */
try {
await fs.promises.mkdir(dirPath)
} catch (error: any) {
if (error.code === FileErrorCodes.FileAlreadyExists) {
/**
* A concurrent process must have created the directory already.
* Make sure it *is* a directory and not something else.
*/
await this.ensureDirectoryExists(dirPath)
} else {
throw error
}
}
} else {
throw error
}
}
}
async deleteDir(dirPath: string): Promise<Result<string>> {
try {
fse.removeSync(dirPath)
return Result.ok('Directory deleted successfully')
} catch (error) {
return Result.fail((error as Error).message)
}
}
async deleteDirContents(dirPath: string): Promise<void> {
/**
* Scan the directory up to ten times, to handle cases where files are being added while
* the directory's contents are being deleted
*/
for (let i = 1, maxTries = 10; i < maxTries; i++) {
const children = await fs.promises.readdir(dirPath, {
withFileTypes: true,
})
if (children.length === 0) {
break
}
for (const child of children) {
const childPath = path.join(dirPath, child.name)
if (child.isDirectory()) {
await this.deleteDirContents(childPath)
try {
await fs.promises.rmdir(childPath)
} catch (error) {
if (error !== FileErrorCodes.FileDoesNotExist) {
throw error
}
}
} else {
await this.deleteFile(childPath)
}
}
}
}
isChildOfDir(parent: string, potentialChild: string): boolean {
const relative = path.relative(parent, potentialChild)
return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative)
}
async moveDirectory(dir: string, destination: string): Promise<Result<string>> {
try {
await fse.move(dir, destination, { overwrite: true })
return Result.ok('Directory moved successfully')
} catch (error) {
return Result.fail((error as Error).message)
}
}
async moveDirContents(srcDir: string, destDir: string): Promise<Result<string>> {
try {
let srcDirectoryContents = await fs.promises.readdir(srcDir)
await this.ensureDirectoryExists(destDir)
if (this.isChildOfDir(srcDir, destDir)) {
srcDirectoryContents = srcDirectoryContents.filter((name) => {
return !this.isChildOfDir(destDir, path.join(srcDir, name))
})
removeFromArray(srcDirectoryContents, path.basename(destDir))
}
const directoryNames = []
const fileNames = []
for (const contentName of srcDirectoryContents) {
const stats = await fs.promises.lstat(path.join(srcDir, contentName))
if (stats.isDirectory()) {
directoryNames.push(contentName)
continue
}
fileNames.push(contentName)
}
for (const directoryName of directoryNames) {
const result = await this.moveDirContents(path.join(srcDir, directoryName), path.join(destDir, directoryName))
if (result.isFailed()) {
return result
}
}
await this.moveFiles(
fileNames.map((fileName) => path.join(srcDir, fileName)),
destDir,
)
return Result.ok('Directory contents moved successfully')
} catch (error) {
console.error(error)
return Result.fail(`Could not move directory contentes: ${(error as Error).message}`)
}
}
async extractZip(source: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
yauzl.open(source, { lazyEntries: true, autoClose: true }, (err, zipFile) => {
let cancelled = false
const tryReject = (err: Error) => {
if (!cancelled) {
cancelled = true
reject(err)
}
}
if (err) {
return tryReject(err)
}
if (!zipFile) {
return tryReject(new Error('zipFile === undefined'))
}
zipFile.readEntry()
zipFile.on('close', resolve)
zipFile.on('entry', (entry) => {
if (cancelled) {
return
}
const isEntryDirectory = entry.fileName.endsWith('/')
if (isEntryDirectory) {
zipFile.readEntry()
return
}
zipFile.openReadStream(entry, async (err, stream) => {
if (cancelled) {
return
}
if (err) {
return tryReject(err)
}
if (!stream) {
return tryReject(new Error('stream === undefined'))
}
stream.on('error', tryReject)
const filepath = path.join(dest, entry.fileName)
try {
await this.ensureDirectoryExists(path.dirname(filepath))
} catch (error: any) {
return tryReject(error)
}
const writeStream = fs.createWriteStream(filepath).on('error', tryReject).on('error', tryReject)
stream.pipe(writeStream).on('close', () => {
zipFile.readEntry()
})
})
})
})
})
}
async moveFiles(sources: string[], destDir: string): Promise<void[]> {
await this.ensureDirectoryExists(destDir)
return Promise.all(sources.map((fileName) => this.moveFile(fileName, path.join(destDir, path.basename(fileName)))))
}
async moveFile(source: PathLike, destination: PathLike): Promise<void> {
try {
await fs.promises.rename(source, destination)
} catch (_error) {
/** Fall back to copying and then deleting. */
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_FICLONE_FORCE)
await fs.promises.unlink(source)
}
}
async deleteFileIfExists(filePath: PathLike): Promise<void> {
try {
await this.deleteFile(filePath)
} catch {
return
}
}
async deleteFile(filePath: PathLike): Promise<void> {
for (let i = 1, maxTries = 10; i < maxTries; i++) {
try {
await fs.promises.unlink(filePath)
break
} catch (error: any) {
if (error.code === FileErrorCodes.OperationNotPermitted || error.code === FileErrorCodes.DeviceIsBusy) {
await new Promise((resolve) => setTimeout(resolve, 300))
continue
} else if (error.code === FileErrorCodes.FileDoesNotExist) {
/** Already deleted */
break
}
throw error
}
}
}
}

View File

@@ -0,0 +1,23 @@
import { Result } from '@standardnotes/domain-core'
import { PathLike } from 'fs'
export interface FilesManagerInterface {
debouncedJSONDiskWriter(durationMs: number, location: string, data: () => unknown): () => void
openDirectoryPicker(buttonLabel?: string): Promise<string | undefined>
readJSONFile<T>(filepath: string): Promise<T | undefined>
readJSONFileSync<T>(filepath: string): T
writeJSONFile(filepath: string, data: unknown): Promise<void>
writeFile(filepath: string, data: string): Promise<void>
writeJSONFileSync(filepath: string, data: unknown): void
ensureDirectoryExists(dirPath: string): Promise<void>
deleteDir(dirPath: string): Promise<Result<string>>
deleteDirContents(dirPath: string): Promise<void>
isChildOfDir(parent: string, potentialChild: string): boolean
moveDirectory(dir: string, destination: string): Promise<Result<string>>
moveDirContents(srcDir: string, destDir: string): Promise<Result<string>>
extractZip(source: string, dest: string): Promise<void>
moveFiles(sources: string[], destDir: string): Promise<void[]>
moveFile(source: PathLike, destination: PathLike): Promise<void>
deleteFileIfExists(filePath: PathLike): Promise<void>
deleteFile(filePath: PathLike): Promise<void>
}

View File

@@ -9,23 +9,14 @@ import {
} from '@web/Application/Device/DesktopSnjsExports'
import { AppState } from 'app/AppState'
import { promises as fs, existsSync } from 'fs'
import { WebContents, shell } from 'electron'
import { WebContents } from 'electron'
import { StoreKeys } from '../Store/StoreKeys'
import path from 'path'
import {
deleteFileIfExists,
ensureDirectoryExists,
moveDirContents,
moveFile,
openDirectoryPicker,
readJSONFile,
writeFile,
writeJSONFile,
} from '../Utils/FileUtils'
import { FileDownloader } from './FileDownloader'
import { FileReadOperation } from './FileReadOperation'
import { Paths } from '../Types/Paths'
import { MessageToWebApp } from '../../Shared/IpcMessages'
import { FilesManagerInterface } from '../File/FilesManagerInterface'
const TextBackupFileExtension = '.txt'
@@ -39,7 +30,11 @@ export class FilesBackupManager implements FileBackupsDevice {
private readOperations: Map<string, FileReadOperation> = new Map()
private plaintextMappingCache?: PlaintextBackupsMapping
constructor(private appState: AppState, private webContents: WebContents) {}
constructor(
private appState: AppState,
private webContents: WebContents,
private filesManager: FilesManagerInterface,
) {}
private async findUuidForPlaintextBackupFileName(
backupsDirectory: string,
@@ -72,16 +67,16 @@ export class FilesBackupManager implements FileBackupsDevice {
return
}
await ensureDirectoryExists(newLocation)
await this.filesManager.ensureDirectoryExists(newLocation)
const legacyMappingLocation = path.join(legacyLocation, 'info.json')
const newMappingLocation = this.getFileBackupsMappingFilePath(newLocation)
await ensureDirectoryExists(path.dirname(newMappingLocation))
await this.filesManager.ensureDirectoryExists(path.dirname(newMappingLocation))
if (existsSync(legacyMappingLocation)) {
await moveFile(legacyMappingLocation, newMappingLocation)
await this.filesManager.moveFile(legacyMappingLocation, newMappingLocation)
}
await moveDirContents(legacyLocation, newLocation)
await this.filesManager.moveDirContents(legacyLocation, newLocation)
}
public async isLegacyFilesBackupsEnabled(): Promise<boolean> {
@@ -111,33 +106,12 @@ export class FilesBackupManager implements FileBackupsDevice {
return path.join(Paths.homeDir, LegacyTextBackupsDirectory)
}
public async presentDirectoryPickerForLocationChangeAndTransferOld(
appendPath: string,
oldLocation?: string,
): Promise<string | undefined> {
const selectedDirectory = await openDirectoryPicker('Select')
if (!selectedDirectory) {
return undefined
}
const newPath = path.join(selectedDirectory, path.normalize(appendPath))
await ensureDirectoryExists(newPath)
if (oldLocation) {
await moveDirContents(path.normalize(oldLocation), newPath)
}
return newPath
}
private getFileBackupsMappingFilePath(backupsLocation: string): string {
return path.join(backupsLocation, '.settings', 'info.json')
}
private async getFileBackupsMappingFileFromDisk(backupsLocation: string): Promise<FileBackupsMapping | undefined> {
return readJSONFile<FileBackupsMapping>(this.getFileBackupsMappingFilePath(backupsLocation))
return this.filesManager.readJSONFile<FileBackupsMapping>(this.getFileBackupsMappingFilePath(backupsLocation))
}
private defaulFileBackupstMappingFileValue(): FileBackupsMapping {
@@ -158,12 +132,8 @@ export class FilesBackupManager implements FileBackupsDevice {
return data
}
async openLocation(location: string): Promise<void> {
void shell.openPath(location)
}
private async saveFilesBackupsMappingFile(location: string, file: FileBackupsMapping): Promise<'success' | 'failed'> {
await writeJSONFile(this.getFileBackupsMappingFilePath(location), file)
await this.filesManager.writeJSONFile(this.getFileBackupsMappingFilePath(location), file)
return 'success'
}
@@ -182,9 +152,9 @@ export class FilesBackupManager implements FileBackupsDevice {
const metaFilePath = path.join(fileDir, FileBackupsConstantsV1.MetadataFileName)
const binaryPath = path.join(fileDir, FileBackupsConstantsV1.BinaryFileName)
await ensureDirectoryExists(fileDir)
await this.filesManager.ensureDirectoryExists(fileDir)
await writeFile(metaFilePath, metaFile)
await this.filesManager.writeFile(metaFilePath, metaFile)
const downloader = new FileDownloader(
downloadRequest.chunkSizes,
@@ -247,7 +217,7 @@ export class FilesBackupManager implements FileBackupsDevice {
let success: boolean
try {
await ensureDirectoryExists(location)
await this.filesManager.ensureDirectoryExists(location)
const name = `${new Date().toISOString().replace(/:/g, '-')}${TextBackupFileExtension}`
const filePath = path.join(location, name)
await fs.writeFile(filePath, data)
@@ -262,7 +232,7 @@ export class FilesBackupManager implements FileBackupsDevice {
async copyDecryptScript(location: string) {
try {
await ensureDirectoryExists(location)
await this.filesManager.ensureDirectoryExists(location)
await fs.copyFile(Paths.decryptScript, path.join(location, path.basename(Paths.decryptScript)))
} catch (error) {
console.error(error)
@@ -274,14 +244,14 @@ export class FilesBackupManager implements FileBackupsDevice {
}
private async getPlaintextMappingFileFromDisk(location: string): Promise<PlaintextBackupsMapping | undefined> {
return readJSONFile<PlaintextBackupsMapping>(this.getPlaintextMappingFilePath(location))
return this.filesManager.readJSONFile<PlaintextBackupsMapping>(this.getPlaintextMappingFilePath(location))
}
private async savePlaintextBackupsMappingFile(
location: string,
file: PlaintextBackupsMapping,
): Promise<'success' | 'failed'> {
await writeJSONFile(this.getPlaintextMappingFilePath(location), file)
await this.filesManager.writeJSONFile(this.getPlaintextMappingFilePath(location), file)
return 'success'
}
@@ -324,7 +294,7 @@ export class FilesBackupManager implements FileBackupsDevice {
const records = mapping.files[uuid]
for (const record of records) {
const filePath = path.join(location, record.path)
await deleteFileIfExists(filePath)
await this.filesManager.deleteFileIfExists(filePath)
}
mapping.files[uuid] = []
}
@@ -337,12 +307,12 @@ export class FilesBackupManager implements FileBackupsDevice {
return records.find((record) => record.tag === tag)
}
await ensureDirectoryExists(absolutePath)
await this.filesManager.ensureDirectoryExists(absolutePath)
const relativePath = forTag ?? ''
const filenameWithSlashesEscaped = filename.replace(/\//g, '\u2215')
const fileAbsolutePath = path.join(absolutePath, relativePath, filenameWithSlashesEscaped)
await writeFile(fileAbsolutePath, data)
await this.filesManager.writeFile(fileAbsolutePath, data)
const existingRecord = findMappingRecord(forTag)
if (!existingRecord) {
@@ -385,6 +355,10 @@ export class FilesBackupManager implements FileBackupsDevice {
const watcher = fs.watch(backupsDirectory, { recursive: true })
for await (const event of watcher) {
const { eventType, filename } = event
if (!filename) {
continue
}
if (eventType !== 'change' && eventType !== 'rename') {
continue
}

View File

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

View File

@@ -0,0 +1,254 @@
import path from 'path'
import {
HomeServerManagerInterface,
HomeServerEnvironmentConfiguration,
} from '@web/Application/Device/DesktopSnjsExports'
import { HomeServerInterface } from '@standardnotes/home-server'
import { WebContents } from 'electron'
import { MessageToWebApp } from '../../Shared/IpcMessages'
import { FilesManagerInterface } from '../File/FilesManagerInterface'
import { HomeServerConfigurationFile } from './HomeServerConfigurationFile'
const os = require('os')
export class HomeServerManager implements HomeServerManagerInterface {
private readonly HOME_SERVER_CONFIGURATION_FILE_NAME = 'config.json'
private homeServerConfiguration: HomeServerEnvironmentConfiguration | undefined
private homeServerDataLocation: string | undefined
private lastErrorMessage: string | undefined
private logs: string[] = []
private readonly LOGS_BUFFER_SIZE = 1000
constructor(
private homeServer: HomeServerInterface,
private webContents: WebContents,
private filesManager: FilesManagerInterface,
) {}
async getHomeServerUrl(): Promise<string | undefined> {
const homeServerConfiguration = await this.getHomeServerConfigurationObject()
if (!homeServerConfiguration) {
return undefined
}
return `http://${this.getLocalIP()}:${homeServerConfiguration.port}`
}
async isHomeServerRunning(): Promise<boolean> {
return this.homeServer.isRunning()
}
async getHomeServerLastErrorMessage(): Promise<string | undefined> {
return this.lastErrorMessage
}
async activatePremiumFeatures(username: string): Promise<string | undefined> {
const result = await this.homeServer.activatePremiumFeatures(username)
if (result.isFailed()) {
return result.getError()
}
}
async getHomeServerConfiguration(): Promise<string | undefined> {
if (this.homeServerConfiguration) {
return JSON.stringify(this.homeServerConfiguration)
}
if (!this.homeServerDataLocation) {
return undefined
}
const homeServerConfiguration = await this.filesManager.readJSONFile<HomeServerConfigurationFile>(
path.join(this.homeServerDataLocation, this.HOME_SERVER_CONFIGURATION_FILE_NAME),
)
if (!homeServerConfiguration) {
return undefined
}
return JSON.stringify(homeServerConfiguration.configuration)
}
async setHomeServerConfiguration(configurationJSONString: string): Promise<void> {
try {
if (!this.homeServerDataLocation) {
throw new Error('Home server data location is not set.')
}
const homeServerConfiguration = JSON.parse(configurationJSONString) as HomeServerEnvironmentConfiguration
await this.filesManager.ensureDirectoryExists(this.homeServerDataLocation)
const configurationFile: HomeServerConfigurationFile = {
version: '1.0.0',
info: {
warning: 'Do not edit this file.',
information:
'The values below are encrypted with a key created by the desktop application after installation. The key is stored in your secure device keychain.',
instructions:
'Put this file inside your home server data location to restore your home server configuration.',
},
configuration: homeServerConfiguration,
}
await this.filesManager.writeJSONFile(
path.join(this.homeServerDataLocation, this.HOME_SERVER_CONFIGURATION_FILE_NAME),
configurationFile,
)
this.homeServerConfiguration = homeServerConfiguration
} catch (error) {
console.error(`Could not save server configuration: ${(error as Error).message}`)
}
}
async setHomeServerDataLocation(location: string): Promise<void> {
this.homeServerDataLocation = location
}
async stopHomeServer(): Promise<string | undefined> {
const result = await this.homeServer.stop()
if (result.isFailed()) {
return result.getError()
}
return undefined
}
async startHomeServer(): Promise<string | undefined> {
try {
this.lastErrorMessage = undefined
this.logs = []
let homeServerConfiguration = await this.getHomeServerConfigurationObject()
if (!homeServerConfiguration) {
homeServerConfiguration = this.generateHomeServerConfiguration()
}
await this.setHomeServerConfiguration(JSON.stringify(homeServerConfiguration))
if (!this.homeServerDataLocation) {
this.lastErrorMessage = 'Home server data location is not set.'
return this.lastErrorMessage
}
const {
jwtSecret,
authJwtSecret,
encryptionServerKey,
pseudoKeyParamsKey,
valetTokenSecret,
port,
logLevel,
databaseEngine,
mysqlConfiguration,
} = homeServerConfiguration as HomeServerEnvironmentConfiguration
const environment: { [name: string]: string } = {
JWT_SECRET: jwtSecret,
AUTH_JWT_SECRET: authJwtSecret,
ENCRYPTION_SERVER_KEY: encryptionServerKey,
PSEUDO_KEY_PARAMS_KEY: pseudoKeyParamsKey,
VALET_TOKEN_SECRET: valetTokenSecret,
FILES_SERVER_URL: (await this.getHomeServerUrl()) as string,
LOG_LEVEL: logLevel ?? 'info',
VERSION: 'desktop',
PORT: port.toString(),
DB_TYPE: databaseEngine,
}
if (mysqlConfiguration !== undefined) {
environment.DB_HOST = mysqlConfiguration.host
if (mysqlConfiguration.port) {
environment.DB_PORT = mysqlConfiguration.port.toString()
}
environment.DB_USERNAME = mysqlConfiguration.username
environment.DB_PASSWORD = mysqlConfiguration.password
environment.DB_DATABASE = mysqlConfiguration.database
}
const result = await this.homeServer.start({
dataDirectoryPath: this.homeServerDataLocation,
environment,
logStreamCallback: this.appendLogs.bind(this),
})
if (result.isFailed()) {
this.lastErrorMessage = result.getError()
return this.lastErrorMessage
}
this.webContents.send(MessageToWebApp.HomeServerStarted, await this.getHomeServerUrl())
} catch (error) {
return (error as Error).message
}
}
async getHomeServerLogs(): Promise<string[]> {
return this.logs
}
private appendLogs(log: Buffer): void {
this.logs.push(log.toString())
if (this.logs.length > this.LOGS_BUFFER_SIZE) {
this.logs.shift()
}
}
private generateRandomKey(length: number): string {
return require('crypto').randomBytes(length).toString('hex')
}
private getLocalIP() {
const interfaces = os.networkInterfaces()
for (const interfaceName in interfaces) {
const addresses = interfaces[interfaceName]
for (const address of addresses) {
if (address.family === 'IPv4' && !address.internal) {
return address.address
}
}
}
}
private async getHomeServerConfigurationObject(): Promise<HomeServerEnvironmentConfiguration | undefined> {
try {
const homeServerConfigurationJSON = await this.getHomeServerConfiguration()
if (!homeServerConfigurationJSON) {
return undefined
}
return JSON.parse(homeServerConfigurationJSON)
} catch (error) {
console.error(`Could not get home server configuration: ${(error as Error).message}`)
}
}
private generateHomeServerConfiguration(): HomeServerEnvironmentConfiguration {
const jwtSecret = this.generateRandomKey(32)
const authJwtSecret = this.generateRandomKey(32)
const encryptionServerKey = this.generateRandomKey(32)
const pseudoKeyParamsKey = this.generateRandomKey(32)
const valetTokenSecret = this.generateRandomKey(32)
const port = 3127
const configuration: HomeServerEnvironmentConfiguration = {
jwtSecret,
authJwtSecret,
encryptionServerKey,
pseudoKeyParamsKey,
valetTokenSecret,
port,
databaseEngine: 'sqlite',
logLevel: 'info',
}
return configuration
}
}

View File

@@ -4,9 +4,9 @@ import path from 'path'
import { pipeline as pipelineFn } from 'stream'
import { promisify } from 'util'
import { MessageType } from '../../../../test/TestIpcMessage'
import { ensureDirectoryExists } from '../Utils/FileUtils'
import { handleTestMessage } from '../Utils/Testing'
import { isTesting } from '../Utils/Utils'
import { FilesManager } from '../File/FilesManager'
const pipeline = promisify(pipelineFn)
@@ -21,7 +21,7 @@ if (isTesting()) {
* not exist)
*/
export async function downloadFile(url: string, filePath: string): Promise<void> {
await ensureDirectoryExists(path.dirname(filePath))
await new FilesManager().ensureDirectoryExists(path.dirname(filePath))
const response = await get(url)
await pipeline(
/**

View File

@@ -5,19 +5,12 @@ import path from 'path'
import { MessageToWebApp } from '../../Shared/IpcMessages'
import { AppName } from '../Strings'
import { Paths } from '../Types/Paths'
import {
debouncedJSONDiskWriter,
deleteDir,
deleteDirContents,
ensureDirectoryExists,
extractZip,
FileDoesNotExist,
moveDirContents,
readJSONFile,
} from '../Utils/FileUtils'
import { timeout } from '../Utils/Utils'
import { downloadFile, getJSON } from './Networking'
import { Component, MappingFile, PackageInfo, PackageManagerInterface, SyncTask } from './PackageManagerInterface'
import { FilesManagerInterface } from '../File/FilesManagerInterface'
import { FilesManager } from '../File/FilesManager'
import { FileErrorCodes } from '../File/FileErrorCodes'
function logMessage(...message: any) {
log.info('PackageManager[Info]:', ...message)
@@ -33,26 +26,27 @@ function logError(...message: any) {
class MappingFileHandler {
static async create() {
let mapping: MappingFile
const filesManager = new FilesManager()
try {
const result = await readJSONFile<MappingFile>(Paths.extensionsMappingJson)
const result = await filesManager.readJSONFile<MappingFile>(Paths.extensionsMappingJson)
mapping = result || {}
} catch (error: any) {
/**
* Mapping file might be absent (first start, corrupted data)
*/
if (error.code === FileDoesNotExist) {
await ensureDirectoryExists(path.dirname(Paths.extensionsMappingJson))
if (error.code === FileErrorCodes.FileDoesNotExist) {
await filesManager.ensureDirectoryExists(path.dirname(Paths.extensionsMappingJson))
} else {
logError(error)
}
mapping = {}
}
return new MappingFileHandler(mapping)
return new MappingFileHandler(mapping, filesManager)
}
constructor(private mapping: MappingFile) {}
constructor(private mapping: MappingFile, private filesManager: FilesManagerInterface) {}
get = (componendId: string) => {
return this.mapping[componendId]
@@ -63,12 +57,14 @@ class MappingFileHandler {
location,
version,
}
this.writeToDisk()
this.filesManager.debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
}
remove = (componentId: string) => {
delete this.mapping[componentId]
this.writeToDisk()
this.filesManager.debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
}
getInstalledVersionForComponent = async (component: Component): Promise<string> => {
@@ -83,15 +79,13 @@ class MappingFileHandler {
*/
const paths = pathsForComponent(component)
const packagePath = path.join(paths.absolutePath, 'package.json')
const response = await readJSONFile<{ version: string }>(packagePath)
const response = await this.filesManager.readJSONFile<{ version: string }>(packagePath)
if (!response) {
return ''
}
this.set(component.uuid, paths.relativePath, response.version)
return response.version
}
private writeToDisk = debouncedJSONDiskWriter(100, Paths.extensionsMappingJson, () => this.mapping)
}
export async function initializePackageManager(webContents: Electron.WebContents): Promise<PackageManagerInterface> {
@@ -230,7 +224,7 @@ async function syncComponents(webContents: Electron.WebContents, mapping: Mappin
await checkForUpdate(webContents, mapping, component)
}
} catch (error: any) {
if (error.code === FileDoesNotExist) {
if (error.code === FileErrorCodes.FileDoesNotExist) {
/** We have a component but no content. Install the component */
await installComponent(webContents, mapping, component, component.content.package_info, version)
} else {
@@ -289,7 +283,7 @@ async function unnestLegacyStructure(dir: string) {
const sourceDir = path.join(dir, fileNames[0])
const destDir = dir
await moveDirContents(sourceDir, destDir)
await new FilesManager().moveDirContents(sourceDir, destDir)
}
async function installComponent(
@@ -331,13 +325,15 @@ async function installComponent(
downloadFile(downloadUrl, paths.downloadPath),
(async () => {
/** Clear the component's directory before extracting the zip. */
await ensureDirectoryExists(paths.absolutePath)
await deleteDirContents(paths.absolutePath)
const filesManager = new FilesManager()
await filesManager.ensureDirectoryExists(paths.absolutePath)
await filesManager.deleteDirContents(paths.absolutePath)
})(),
])
logMessage('Extracting', paths.downloadPath, 'to', paths.absolutePath)
await extractZip(paths.downloadPath, paths.absolutePath)
const filesManager = new FilesManager()
await filesManager.extractZip(paths.downloadPath, paths.absolutePath)
const legacyStructure = await usesLegacyNestedFolderStructure(paths.absolutePath)
if (legacyStructure) {
@@ -348,7 +344,7 @@ async function installComponent(
try {
/** Try to read 'sn.main' field from 'package.json' file */
const packageJsonPath = path.join(paths.absolutePath, 'package.json')
const packageJson = await readJSONFile<{
const packageJson = await filesManager.readJSONFile<{
sn?: { main?: string }
version?: string
}>(packageJsonPath)
@@ -403,6 +399,8 @@ async function uninstallComponent(mapping: MappingFileHandler, uuid: string) {
/** No mapping for component */
return
}
await deleteDir(path.join(Paths.userDataDir, componentMapping.location))
mapping.remove(uuid)
const result = await new FilesManager().deleteDir(path.join(Paths.userDataDir, componentMapping.location))
if (!result.isFailed()) {
mapping.remove(uuid)
}
}

View File

@@ -10,7 +10,9 @@ import {
FileBackupsMapping,
FileBackupReadToken,
FileBackupReadChunkResponse,
HomeServerManagerInterface,
PlaintextBackupsMapping,
DirectoryManagerInterface,
} from '@web/Application/Device/DesktopSnjsExports'
import { app, BrowserWindow } from 'electron'
import { KeychainInterface } from '../Keychain/KeychainInterface'
@@ -34,6 +36,8 @@ export class RemoteBridge implements CrossProcessBridge {
private menus: MenuManagerInterface,
private fileBackups: FileBackupsDevice,
private media: MediaManagerInterface,
private homeServerManager: HomeServerManagerInterface,
private directoryManager: DirectoryManagerInterface,
) {}
get exposableValue(): CrossProcessBridge {
@@ -63,6 +67,8 @@ export class RemoteBridge implements CrossProcessBridge {
getFileBackupReadToken: this.getFileBackupReadToken.bind(this),
readNextChunk: this.readNextChunk.bind(this),
askForMediaAccess: this.askForMediaAccess.bind(this),
startHomeServer: this.startHomeServer.bind(this),
stopHomeServer: this.stopHomeServer.bind(this),
wasLegacyTextBackupsExplicitlyDisabled: this.wasLegacyTextBackupsExplicitlyDisabled.bind(this),
getLegacyTextBackupsLocation: this.getLegacyTextBackupsLocation.bind(this),
saveTextBackupData: this.saveTextBackupData.bind(this),
@@ -70,6 +76,7 @@ export class RemoteBridge implements CrossProcessBridge {
openLocation: this.openLocation.bind(this),
presentDirectoryPickerForLocationChangeAndTransferOld:
this.presentDirectoryPickerForLocationChangeAndTransferOld.bind(this),
getDirectoryManagerLastErrorMessage: this.getDirectoryManagerLastErrorMessage.bind(this),
getPlaintextBackupsMappingFile: this.getPlaintextBackupsMappingFile.bind(this),
persistPlaintextBackupsMappingFile: this.persistPlaintextBackupsMappingFile.bind(this),
getTextBackupsCount: this.getTextBackupsCount.bind(this),
@@ -77,6 +84,14 @@ export class RemoteBridge implements CrossProcessBridge {
getUserDocumentsDirectory: this.getUserDocumentsDirectory.bind(this),
monitorPlaintextBackupsLocationForChanges: this.monitorPlaintextBackupsLocationForChanges.bind(this),
joinPaths: this.joinPaths.bind(this),
setHomeServerConfiguration: this.setHomeServerConfiguration.bind(this),
getHomeServerConfiguration: this.getHomeServerConfiguration.bind(this),
setHomeServerDataLocation: this.setHomeServerDataLocation.bind(this),
activatePremiumFeatures: this.activatePremiumFeatures.bind(this),
isHomeServerRunning: this.isHomeServerRunning.bind(this),
getHomeServerLogs: this.getHomeServerLogs.bind(this),
getHomeServerUrl: this.getHomeServerUrl.bind(this),
getHomeServerLastErrorMessage: this.getHomeServerLastErrorMessage.bind(this),
}
}
@@ -201,15 +216,19 @@ export class RemoteBridge implements CrossProcessBridge {
return this.fileBackups.savePlaintextNoteBackup(location, uuid, name, tags, data)
}
openLocation(path: string): Promise<void> {
return this.fileBackups.openLocation(path)
async openLocation(path: string): Promise<void> {
return this.directoryManager.openLocation(path)
}
presentDirectoryPickerForLocationChangeAndTransferOld(
async presentDirectoryPickerForLocationChangeAndTransferOld(
appendPath: string,
oldLocation?: string | undefined,
): Promise<string | undefined> {
return this.fileBackups.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation)
return this.directoryManager.presentDirectoryPickerForLocationChangeAndTransferOld(appendPath, oldLocation)
}
async getDirectoryManagerLastErrorMessage(): Promise<string | undefined> {
return this.directoryManager.getDirectoryManagerLastErrorMessage()
}
getPlaintextBackupsMappingFile(location: string): Promise<PlaintextBackupsMapping> {
@@ -243,4 +262,44 @@ export class RemoteBridge implements CrossProcessBridge {
askForMediaAccess(type: 'camera' | 'microphone'): Promise<boolean> {
return this.media.askForMediaAccess(type)
}
async startHomeServer(): Promise<string | undefined> {
return this.homeServerManager.startHomeServer()
}
async stopHomeServer(): Promise<string | undefined> {
return this.homeServerManager.stopHomeServer()
}
async setHomeServerConfiguration(configurationJSONString: string): Promise<void> {
return this.homeServerManager.setHomeServerConfiguration(configurationJSONString)
}
async getHomeServerConfiguration(): Promise<string | undefined> {
return this.homeServerManager.getHomeServerConfiguration()
}
async setHomeServerDataLocation(location: string): Promise<void> {
return this.homeServerManager.setHomeServerDataLocation(location)
}
async activatePremiumFeatures(username: string): Promise<string | undefined> {
return this.homeServerManager.activatePremiumFeatures(username)
}
async isHomeServerRunning(): Promise<boolean> {
return this.homeServerManager.isHomeServerRunning()
}
async getHomeServerLogs(): Promise<string[]> {
return this.homeServerManager.getHomeServerLogs()
}
async getHomeServerUrl(): Promise<string | undefined> {
return this.homeServerManager.getHomeServerUrl()
}
async getHomeServerLastErrorMessage(): Promise<string | undefined> {
return this.homeServerManager.getHomeServerLastErrorMessage()
}
}

View File

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

View File

@@ -1,9 +1,9 @@
import fs from 'fs'
import { Language } from '../SpellcheckerManager'
import { FileDoesNotExist } from '../Utils/FileUtils'
import { ensureIsBoolean, isBoolean } from '../Utils/Utils'
import { StoreData, StoreKeys } from './StoreKeys'
import { logError } from './Store'
import { FileErrorCodes } from '../File/FileErrorCodes'
export function createSanitizedStoreData(data: any = {}): StoreData {
return {
@@ -69,7 +69,7 @@ export function parseDataFile(filePath: string) {
return createSanitizedStoreData(userData)
} catch (error: any) {
console.log('Error reading store file', error)
if (error.code !== FileDoesNotExist) {
if (error.code !== FileErrorCodes.FileDoesNotExist) {
logError(error)
}

View File

@@ -1,291 +0,0 @@
import { dialog } from 'electron'
import fs, { PathLike } from 'fs'
import { debounce } from 'lodash'
import path from 'path'
import yauzl from 'yauzl'
import { removeFromArray } from '../Utils/Utils'
export const FileDoesNotExist = 'ENOENT'
export const FileAlreadyExists = 'EEXIST'
const OperationNotPermitted = 'EPERM'
const DeviceIsBusy = 'EBUSY'
export function debouncedJSONDiskWriter(durationMs: number, location: string, data: () => unknown): () => void {
let writingToDisk = false
return debounce(async () => {
if (writingToDisk) {
return
}
writingToDisk = true
try {
await writeJSONFile(location, data())
} catch (error) {
console.error(error)
} finally {
writingToDisk = false
}
}, durationMs)
}
export async function openDirectoryPicker(buttonLabel?: string): Promise<string | undefined> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'],
buttonLabel: buttonLabel,
})
return result.filePaths[0]
}
export async function readJSONFile<T>(filepath: string): Promise<T | undefined> {
try {
const data = await fs.promises.readFile(filepath, 'utf8')
return JSON.parse(data)
} catch (error) {
return undefined
}
}
export function readJSONFileSync<T>(filepath: string): T {
const data = fs.readFileSync(filepath, 'utf8')
return JSON.parse(data)
}
export async function writeJSONFile(filepath: string, data: unknown): Promise<void> {
await ensureDirectoryExists(path.dirname(filepath))
await fs.promises.writeFile(filepath, JSON.stringify(data, null, 2), 'utf8')
}
export async function writeFile(filepath: string, data: string): Promise<void> {
await ensureDirectoryExists(path.dirname(filepath))
await fs.promises.writeFile(filepath, data, 'utf8')
}
export function writeJSONFileSync(filepath: string, data: unknown): void {
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8')
}
/** Creates the directory if it doesn't exist. */
export async function ensureDirectoryExists(dirPath: string): Promise<void> {
try {
const stat = await fs.promises.lstat(dirPath)
if (!stat.isDirectory()) {
throw new Error('Tried to create a directory where a file of the same ' + `name already exists: ${dirPath}`)
}
} catch (error: any) {
if (error.code === FileDoesNotExist) {
/**
* No directory here. Make sure there is a *parent* directory, and then
* create it.
*/
await ensureDirectoryExists(path.dirname(dirPath))
/** Now that its parent(s) exist, create the directory */
try {
await fs.promises.mkdir(dirPath)
} catch (error: any) {
if (error.code === FileAlreadyExists) {
/**
* A concurrent process must have created the directory already.
* Make sure it *is* a directory and not something else.
*/
await ensureDirectoryExists(dirPath)
} else {
throw error
}
}
} else {
throw error
}
}
}
/**
* Deletes a directory (handling recursion.)
* @param {string} dirPath the path of the directory
*/
export async function deleteDir(dirPath: string): Promise<void> {
try {
await deleteDirContents(dirPath)
} catch (error: any) {
if (error.code === FileDoesNotExist) {
/** Directory has already been deleted. */
return
}
throw error
}
await fs.promises.rmdir(dirPath)
}
export async function deleteDirContents(dirPath: string): Promise<void> {
/**
* Scan the directory up to ten times, to handle cases where files are being added while
* the directory's contents are being deleted
*/
for (let i = 1, maxTries = 10; i < maxTries; i++) {
const children = await fs.promises.readdir(dirPath, {
withFileTypes: true,
})
if (children.length === 0) {
break
}
for (const child of children) {
const childPath = path.join(dirPath, child.name)
if (child.isDirectory()) {
await deleteDirContents(childPath)
try {
await fs.promises.rmdir(childPath)
} catch (error) {
if (error !== FileDoesNotExist) {
throw error
}
}
} else {
await deleteFile(childPath)
}
}
}
}
function isChildOfDir(parent: string, potentialChild: string) {
const relative = path.relative(parent, potentialChild)
return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
}
export async function moveDirContents(srcDir: string, destDir: string): Promise<void> {
let fileNames: string[]
try {
fileNames = await fs.promises.readdir(srcDir)
} catch (error) {
console.error(error)
return
}
await ensureDirectoryExists(destDir)
if (isChildOfDir(srcDir, destDir)) {
fileNames = fileNames.filter((name) => {
return !isChildOfDir(destDir, path.join(srcDir, name))
})
removeFromArray(fileNames, path.basename(destDir))
}
try {
await moveFiles(
fileNames.map((fileName) => path.join(srcDir, fileName)),
destDir,
)
} catch (error) {
console.error(error)
}
}
export async function extractZip(source: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
yauzl.open(source, { lazyEntries: true, autoClose: true }, (err, zipFile) => {
let cancelled = false
const tryReject = (err: Error) => {
if (!cancelled) {
cancelled = true
reject(err)
}
}
if (err) {
return tryReject(err)
}
if (!zipFile) {
return tryReject(new Error('zipFile === undefined'))
}
zipFile.readEntry()
zipFile.on('close', resolve)
zipFile.on('entry', (entry) => {
if (cancelled) {
return
}
const isEntryDirectory = entry.fileName.endsWith('/')
if (isEntryDirectory) {
zipFile.readEntry()
return
}
zipFile.openReadStream(entry, async (err, stream) => {
if (cancelled) {
return
}
if (err) {
return tryReject(err)
}
if (!stream) {
return tryReject(new Error('stream === undefined'))
}
stream.on('error', tryReject)
const filepath = path.join(dest, entry.fileName)
try {
await ensureDirectoryExists(path.dirname(filepath))
} catch (error: any) {
return tryReject(error)
}
const writeStream = fs.createWriteStream(filepath).on('error', tryReject).on('error', tryReject)
stream.pipe(writeStream).on('close', () => {
zipFile.readEntry()
})
})
})
})
})
}
export async function moveFiles(sources: string[], destDir: string): Promise<void[]> {
await ensureDirectoryExists(destDir)
return Promise.all(sources.map((fileName) => moveFile(fileName, path.join(destDir, path.basename(fileName)))))
}
export async function moveFile(source: PathLike, destination: PathLike) {
try {
await fs.promises.rename(source, destination)
} catch (_error) {
/** Fall back to copying and then deleting. */
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_FICLONE_FORCE)
await fs.promises.unlink(source)
}
}
export async function deleteFileIfExists(filePath: PathLike): Promise<void> {
try {
await deleteFile(filePath)
} catch {
return
}
}
/** Deletes a file, handling EPERM and EBUSY errors on Windows. */
export async function deleteFile(filePath: PathLike): Promise<void> {
for (let i = 1, maxTries = 10; i < maxTries; i++) {
try {
await fs.promises.unlink(filePath)
break
} catch (error: any) {
if (error.code === OperationNotPermitted || error.code === DeviceIsBusy) {
await new Promise((resolve) => setTimeout(resolve, 300))
continue
} else if (error.code === FileDoesNotExist) {
/** Already deleted */
break
}
throw error
}
}
}

View File

@@ -24,6 +24,10 @@ import { checkForUpdate, setupUpdates } from './UpdateManager'
import { handleTestMessage, send } from './Utils/Testing'
import { isTesting, lowercaseDriveLetter } from './Utils/Utils'
import { initializeZoomManager } from './ZoomManager'
import { HomeServerManager } from './HomeServer/HomeServerManager'
import { HomeServer } from '@standardnotes/home-server'
import { FilesManager } from './File/FilesManager'
import { DirectoryManager } from './Directory/DirectoryManager'
const WINDOW_DEFAULT_WIDTH = 1100
const WINDOW_DEFAULT_HEIGHT = 800
@@ -72,6 +76,8 @@ export async function createWindowState({
services.menuManager,
services.fileBackupsManager,
services.mediaManager,
services.homeServerManager,
services.directoryManager,
)
const shouldOpenUrl = (url: string) => url.startsWith('http') || url.startsWith('mailto')
@@ -200,6 +206,11 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
const trayManager = createTrayManager(window, appState.store)
const spellcheckerManager = createSpellcheckerManager(appState.store, window.webContents, appLocale)
const mediaManager = new MediaManager()
const homeServer = new HomeServer()
const filesManager = new FilesManager()
const directoryManager = new DirectoryManager(filesManager)
const homeServerManager = new HomeServerManager(homeServer, window.webContents, filesManager)
if (isTesting()) {
handleTestMessage(MessageType.SpellCheckerManager, () => spellcheckerManager)
@@ -213,7 +224,7 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
spellcheckerManager,
})
const fileBackupsManager = new FilesBackupManager(appState, window.webContents)
const fileBackupsManager = new FilesBackupManager(appState, window.webContents, filesManager)
return {
updateManager,
@@ -224,6 +235,8 @@ async function createWindowServices(window: Electron.BrowserWindow, appState: Ap
searchManager,
fileBackupsManager,
mediaManager,
homeServerManager,
directoryManager,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,4 +8,5 @@ export interface ElectronMainEvents {
setWindowFocusedHandler(handler: MainEventHandler): void
setInstallComponentCompleteHandler(handler: MainEventHandler): void
setWatchedDirectoriesChangeHandler(handler: MainEventHandler): void
setHomeServerStartedHandler(handler: MainEventHandler): void
}

View File

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

View File

@@ -9,6 +9,7 @@
"selfReferences": true
},
"dependencies": {
"@standardnotes/home-server": "^1.11.14",
"keytar": "^7.9.0"
}
}

View File

@@ -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: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('/')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,5 @@
export type HomeServerStatus = {
status: 'on' | 'off'
url?: string
errorMessage?: string
}

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ export enum RawStorageKey {
StorageObject = 'storage',
DescriptorRecord = 'descriptors',
SnjsVersion = 'snjs_version',
HomeServerEnabled = 'home_server_enabled',
HomeServerDataLocation = 'home_serve_data_location',
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ const PREFERENCE_IDS = [
'general',
'account',
'security',
'home-server',
'vaults',
'appearance',
'backups',

View File

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

View File

@@ -10,7 +10,11 @@ export {
FileBackupReadToken,
FileBackupReadChunkResponse,
FileDownloadProgress,
HomeServerManagerInterface,
HomeServerStatus,
PlaintextBackupsMapping,
DesktopWatchedDirectoriesChanges,
DesktopWatchedDirectoriesChange,
HomeServerEnvironmentConfiguration,
DirectoryManagerInterface,
} from '@standardnotes/snjs'

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export type Status = {
state: 'restarting' | 'online' | 'error' | 'offline'
message: string
description?: string | JSX.Element
}

View File

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

View File

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

View File

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

View File

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

View File

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