refactor: repo (#1070)

This commit is contained in:
Mo
2022-06-07 07:18:41 -05:00
committed by GitHub
parent 4c65784421
commit f4ef63693c
1102 changed files with 5786 additions and 3366 deletions

View File

@@ -0,0 +1,209 @@
import { WebCrypto } from '@/Application/Crypto'
import { WebAlertService } from '@/Services/AlertService'
import { ArchiveManager } from '@/Services/ArchiveManager'
import { AutolockService } from '@/Services/AutolockService'
import { DesktopManager } from '@/Services/DesktopManager'
import { IOService } from '@/Services/IOService'
import { ThemeManager } from '@/Services/ThemeManager'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
import {
DeinitSource,
Platform,
SNApplication,
NoteGroupController,
removeFromArray,
IconsController,
DesktopDeviceInterface,
isDesktopDevice,
DeinitMode,
PrefKey,
SNTag,
ContentType,
DecryptedItemInterface,
} from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx'
import { PanelResizedData } from '@/Types/PanelResizedData'
import { WebAppEvent } from './WebAppEvent'
import { isDesktopApplication } from '@/Utils'
type WebServices = {
viewControllerManager: ViewControllerManager
desktopService?: DesktopManager
autolockService: AutolockService
archiveService: ArchiveManager
themeService: ThemeManager
io: IOService
}
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
export class WebApplication extends SNApplication {
private webServices!: WebServices
private webEventObservers: WebEventObserver[] = []
public noteControllerGroup: NoteGroupController
public iconsController: IconsController
private onVisibilityChange: () => void
constructor(
deviceInterface: WebOrDesktopDevice,
platform: Platform,
identifier: string,
defaultSyncServerHost: string,
webSocketUrl: string,
) {
super({
environment: deviceInterface.environment,
platform: platform,
deviceInterface: deviceInterface,
crypto: WebCrypto,
alertService: new WebAlertService(),
identifier,
defaultHost: defaultSyncServerHost,
appVersion: deviceInterface.appVersion,
webSocketUrl: webSocketUrl,
supportsFileNavigation: window.enabledUnfinishedFeatures || false,
})
makeObservable(this, {
dealloced: observable,
})
deviceInterface.setApplication(this)
this.noteControllerGroup = new NoteGroupController(this)
this.iconsController = new IconsController()
this.onVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur
this.notifyWebEvent(event)
}
if (!isDesktopApplication()) {
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
}
override deinit(mode: DeinitMode, source: DeinitSource): void {
super.deinit(mode, source)
try {
for (const service of Object.values(this.webServices)) {
if (!service) {
continue
}
if ('deinit' in service) {
service.deinit?.(source)
}
;(service as { application?: WebApplication }).application = undefined
}
this.webServices = {} as WebServices
this.noteControllerGroup.deinit()
;(this.noteControllerGroup as unknown) = undefined
this.webEventObservers.length = 0
document.removeEventListener('visibilitychange', this.onVisibilityChange)
;(this.onVisibilityChange as unknown) = undefined
} catch (error) {
console.error('Error while deiniting application', error)
}
}
setWebServices(services: WebServices): void {
this.webServices = services
}
public addWebEventObserver(observer: WebEventObserver): () => void {
this.webEventObservers.push(observer)
return () => {
removeFromArray(this.webEventObservers, observer)
}
}
public notifyWebEvent(event: WebAppEvent, data?: unknown): void {
for (const observer of this.webEventObservers) {
observer(event, data)
}
}
publishPanelDidResizeEvent(name: string, collapsed: boolean) {
const data: PanelResizedData = {
panel: name,
collapsed: collapsed,
}
this.notifyWebEvent(WebAppEvent.PanelResized, data)
}
public getViewControllerManager(): ViewControllerManager {
return this.webServices.viewControllerManager
}
public getDesktopService(): DesktopManager | undefined {
return this.webServices.desktopService
}
public getAutolockService() {
return this.webServices.autolockService
}
public getArchiveService() {
return this.webServices.archiveService
}
public get desktopDevice(): DesktopDeviceInterface | undefined {
if (isDesktopDevice(this.deviceInterface)) {
return this.deviceInterface
}
return undefined
}
public getThemeService() {
return this.webServices.themeService
}
public get io() {
return this.webServices.io
}
async checkForSecurityUpdate() {
return this.protocolUpgradeAvailable()
}
downloadBackup(): void | Promise<void> {
if (isDesktopDevice(this.deviceInterface)) {
return this.deviceInterface.downloadBackup()
}
}
async signOutAndDeleteLocalBackups(): Promise<void> {
isDesktopDevice(this.deviceInterface) && (await this.deviceInterface.deleteLocalBackups())
return this.user.signOut()
}
isGlobalSpellcheckEnabled(): boolean {
return this.getPreference(PrefKey.EditorSpellcheck, true)
}
public getItemTags(item: DecryptedItemInterface) {
return this.items.itemsReferencingItem(item).filter((ref) => {
return ref.content_type === ContentType.Tag
}) as SNTag[]
}
public get version(): string {
return (this.deviceInterface as WebOrDesktopDevice).appVersion
}
async toggleGlobalSpellcheck() {
const currentValue = this.isGlobalSpellcheckEnabled()
return this.setPreference(PrefKey.EditorSpellcheck, !currentValue)
}
}

View File

@@ -0,0 +1,80 @@
import { WebApplication } from './Application'
import {
ApplicationDescriptor,
SNApplicationGroup,
Platform,
InternalEventBus,
isDesktopDevice,
} from '@standardnotes/snjs'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { getPlatform, isDesktopApplication } from '@/Utils'
import { ArchiveManager } from '@/Services/ArchiveManager'
import { DesktopManager } from '@/Services/DesktopManager'
import { IOService } from '@/Services/IOService'
import { AutolockService } from '@/Services/AutolockService'
import { ThemeManager } from '@/Services/ThemeManager'
import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
const createApplication = (
descriptor: ApplicationDescriptor,
deviceInterface: WebOrDesktopDevice,
defaultSyncServerHost: string,
device: WebOrDesktopDevice,
webSocketUrl: string,
) => {
const platform = getPlatform()
const application = new WebApplication(
deviceInterface,
platform,
descriptor.identifier,
defaultSyncServerHost,
webSocketUrl,
)
const viewControllerManager = new ViewControllerManager(application, device)
const archiveService = new ArchiveManager(application)
const io = new IOService(platform === Platform.MacWeb || platform === Platform.MacDesktop)
const autolockService = new AutolockService(application, new InternalEventBus())
const themeService = new ThemeManager(application)
application.setWebServices({
viewControllerManager,
archiveService,
desktopService: isDesktopDevice(device) ? new DesktopManager(application, device) : undefined,
io,
autolockService,
themeService,
})
return application
}
export class ApplicationGroup extends SNApplicationGroup<WebOrDesktopDevice> {
constructor(private defaultSyncServerHost: string, device: WebOrDesktopDevice, private webSocketUrl: string) {
super(device)
}
override async initialize(): Promise<void> {
const defaultSyncServerHost = this.defaultSyncServerHost
const webSocketUrl = this.webSocketUrl
await super.initialize({
applicationCreator: async (descriptor, device) => {
return createApplication(descriptor, device, defaultSyncServerHost, device, webSocketUrl)
},
})
if (isDesktopApplication()) {
window.webClient = (this.primaryApplication as WebApplication).getDesktopService()
}
}
override deinit() {
super.deinit()
if (isDesktopApplication()) {
delete window.webClient
}
}
}

View File

@@ -0,0 +1,3 @@
import { SNWebCrypto } from '@standardnotes/sncrypto-web'
export const WebCrypto = new SNWebCrypto()

View File

@@ -0,0 +1,263 @@
import { isString, AlertService, uniqueArray } from '@standardnotes/snjs'
const STORE_NAME = 'items'
const READ_WRITE = 'readwrite'
const OUT_OF_SPACE =
'Unable to save changes locally because your device is out of space. ' +
'Please free up some disk space and try again, otherwise, your data may end ' +
'up in an inconsistent state.'
const DB_DELETION_BLOCKED =
'Your browser is blocking Standard Notes from deleting the local database. ' +
'Make sure there are no other open windows of this app and try again. ' +
'If the issue persists, please manually delete app data to sign out.'
const QUOTE_EXCEEDED_ERROR = 'QuotaExceededError'
export class Database {
private locked = true
private db?: IDBDatabase
constructor(public databaseName: string, private alertService?: AlertService) {}
public deinit(): void {
;(this.alertService as unknown) = undefined
this.db = undefined
}
/**
* Relinquishes the lock and allows db operations to proceed
*/
public unlock(): void {
this.locked = false
}
static async getAllDatabaseNames(): Promise<string[] | undefined> {
if (!window.indexedDB.databases) {
return undefined
}
const rawDatabases = await window.indexedDB.databases()
return rawDatabases.map((db) => db.name).filter((name) => name && name.length > 0) as string[]
}
static async deleteAll(databaseNames: string[]): Promise<void> {
if (window.indexedDB.databases != undefined) {
const idbNames = await this.getAllDatabaseNames()
if (idbNames) {
databaseNames = uniqueArray([...idbNames, ...databaseNames])
}
}
for (const name of databaseNames) {
const db = new Database(name)
await db.clearAllPayloads()
db.deinit()
}
}
/**
* Opens the database natively, or returns the existing database object if already opened.
* @param onNewDatabase - Callback to invoke when a database has been created
* as part of the open process. This can happen on new application sessions, or if the
* browser deleted the database without the user being aware.
*/
public async openDatabase(onNewDatabase?: () => void): Promise<IDBDatabase | undefined> {
if (this.locked) {
throw Error('Attempting to open locked database')
}
if (this.db) {
return this.db
}
const request = window.indexedDB.open(this.databaseName, 1)
return new Promise((resolve, reject) => {
request.onerror = (event) => {
const target = event.target as any
if (target.errorCode) {
this.showAlert('Offline database issue: ' + target.errorCode)
} else {
this.displayOfflineAlert()
}
reject(new Error('Unable to open db'))
}
request.onblocked = (_event) => {
reject(Error('IndexedDB open request blocked'))
}
request.onsuccess = (event) => {
const target = event.target as IDBOpenDBRequest
const db = target.result
db.onversionchange = () => {
db.close()
}
db.onerror = (errorEvent) => {
const target = errorEvent?.target as any
throw Error('Database error: ' + target.errorCode)
}
this.db = db
resolve(db)
}
request.onupgradeneeded = (event) => {
const target = event.target as IDBOpenDBRequest
const db = target.result
db.onversionchange = () => {
db.close()
}
/* Create an objectStore for this database */
const objectStore = db.createObjectStore(STORE_NAME, {
keyPath: 'uuid',
})
objectStore.createIndex('uuid', 'uuid', { unique: true })
objectStore.transaction.oncomplete = () => {
/* Ready to store values in the newly created objectStore. */
if (db.version === 1 && onNewDatabase) {
onNewDatabase && onNewDatabase()
}
}
}
})
}
public async getAllPayloads(): Promise<any[]> {
const db = (await this.openDatabase()) as IDBDatabase
return new Promise((resolve) => {
const objectStore = db.transaction(STORE_NAME).objectStore(STORE_NAME)
const payloads: any = []
const cursorRequest = objectStore.openCursor()
cursorRequest.onsuccess = (event) => {
const target = event.target as any
const cursor = target.result
if (cursor) {
payloads.push(cursor.value)
cursor.continue()
} else {
resolve(payloads)
}
}
})
}
public async getAllKeys(): Promise<string[]> {
const db = (await this.openDatabase()) as IDBDatabase
return new Promise((resolve) => {
const objectStore = db.transaction(STORE_NAME).objectStore(STORE_NAME)
const getAllKeysRequest = objectStore.getAllKeys()
getAllKeysRequest.onsuccess = function () {
const result = getAllKeysRequest.result
const strings = result.map((key) => {
if (isString(key)) {
return key
} else {
return JSON.stringify(key)
}
})
resolve(strings)
}
})
}
public async savePayload(payload: any): Promise<void> {
return this.savePayloads([payload])
}
public async savePayloads(payloads: any[]): Promise<void> {
if (payloads.length === 0) {
return
}
const db = (await this.openDatabase()) as IDBDatabase
const transaction = db.transaction(STORE_NAME, READ_WRITE)
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
transaction.oncomplete = () => {}
transaction.onerror = (event) => {
const target = event.target as any
this.showGenericError(target.error)
}
transaction.onabort = (event) => {
const target = event.target as any
const error = target.error
if (error.name === QUOTE_EXCEEDED_ERROR) {
this.showAlert(OUT_OF_SPACE)
} else {
this.showGenericError(error)
}
reject(error)
}
const objectStore = transaction.objectStore(STORE_NAME)
this.putItems(objectStore, payloads).then(resolve).catch(console.error)
})
}
private async putItems(objectStore: IDBObjectStore, items: any[]): Promise<void> {
await Promise.all(
items.map((item) => {
return new Promise((resolve) => {
const request = objectStore.put(item)
request.onerror = resolve
request.onsuccess = resolve
})
}),
)
}
public async deletePayload(uuid: string): Promise<void> {
const db = (await this.openDatabase()) as IDBDatabase
return new Promise((resolve, reject) => {
const request = db.transaction(STORE_NAME, READ_WRITE).objectStore(STORE_NAME).delete(uuid)
request.onsuccess = () => {
resolve()
}
request.onerror = reject
})
}
public async clearAllPayloads(): Promise<void> {
const deleteRequest = window.indexedDB.deleteDatabase(this.databaseName)
return new Promise((resolve, reject) => {
deleteRequest.onerror = () => {
reject(Error('Error deleting database.'))
}
deleteRequest.onsuccess = () => {
this.db = undefined
resolve()
}
deleteRequest.onblocked = (_event) => {
this.showAlert(DB_DELETION_BLOCKED)
reject(Error('Delete request blocked'))
}
})
}
private showAlert(message: string) {
if (this.alertService) {
this.alertService.alert(message).catch(console.error)
} else {
window.alert(message)
}
}
private showGenericError(error: { code: number; name: string }) {
const message =
'Unable to save changes locally due to an unknown system issue. ' +
`Issue Code: ${error.code} Issue Name: ${error.name}.`
this.showAlert(message)
}
private displayOfflineAlert() {
const message =
'There was an issue loading your offline database. This could happen for two reasons:' +
"\n\n1. You're in a private window in your browser. We can't save your data without " +
'access to the local database. Please use a non-private window.' +
'\n\n2. You have two windows of the app open at the same time. ' +
'Please close any other app instances and reload the page.'
this.showAlert(message)
}
}

View File

@@ -0,0 +1,9 @@
export {
Environment,
RawKeychainValue,
DesktopDeviceInterface,
WebOrDesktopDeviceInterface,
DesktopClientRequiresWebMethods,
FileBackupsMapping,
FileBackupsDevice,
} from '@standardnotes/snjs'

View File

@@ -0,0 +1,8 @@
import { WebOrDesktopDevice } from './WebOrDesktopDevice'
export type StartApplication = (
defaultSyncServerHost: string,
device: WebOrDesktopDevice,
enableUnfinishedFeatures: boolean,
webSocketUrl: string,
) => Promise<void>

View File

@@ -0,0 +1,41 @@
import { Environment, RawKeychainValue } from '@standardnotes/snjs'
import { WebOrDesktopDevice } from './WebOrDesktopDevice'
const KEYCHAIN_STORAGE_KEY = 'keychain'
const DESTROYED_DEVICE_URL_PARAM = 'destroyed'
const DESTROYED_DEVICE_URL_VALUE = 'true'
export class WebDevice extends WebOrDesktopDevice {
environment = Environment.Web
async getKeychainValue(): Promise<RawKeychainValue> {
const value = localStorage.getItem(KEYCHAIN_STORAGE_KEY)
if (value) {
return JSON.parse(value)
}
return {}
}
async setKeychainValue(value: RawKeychainValue): Promise<void> {
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value))
}
async clearRawKeychainValue(): Promise<void> {
localStorage.removeItem(KEYCHAIN_STORAGE_KEY)
}
async performHardReset(): Promise<void> {
const url = new URL(window.location.href)
const params = url.searchParams
params.append(DESTROYED_DEVICE_URL_PARAM, DESTROYED_DEVICE_URL_VALUE)
window.location.replace(url.href)
}
public isDeviceDestroyed(): boolean {
const url = new URL(window.location.href)
const params = url.searchParams
return params.get(DESTROYED_DEVICE_URL_PARAM) === DESTROYED_DEVICE_URL_VALUE
}
}

View File

@@ -0,0 +1,203 @@
import {
SNApplication,
ApplicationIdentifier,
Environment,
LegacyRawKeychainValue,
RawKeychainValue,
TransferPayload,
NamespacedRootKeyInKeychain,
extendArray,
WebOrDesktopDeviceInterface,
} from '@standardnotes/snjs'
import { Database } from '../Database'
export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface {
constructor(public appVersion: string) {}
private databases: Database[] = []
abstract environment: Environment
setApplication(application: SNApplication): void {
const database = new Database(application.identifier, application.alertService)
this.databases.push(database)
}
public async getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined> {
const value = await this.getRawStorageValue(key)
if (value == undefined) {
return undefined
}
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
private databaseForIdentifier(identifier: ApplicationIdentifier) {
return this.databases.find((database) => database.databaseName === identifier) as Database
}
async clearAllDataFromDevice(workspaceIdentifiers: ApplicationIdentifier[]): Promise<{ killsApplication: boolean }> {
await this.clearRawKeychainValue()
await this.removeAllRawStorageValues()
await Database.deleteAll(workspaceIdentifiers)
return { killsApplication: false }
}
deinit() {
for (const database of this.databases) {
database.deinit()
}
this.databases = []
}
async getRawStorageValue(key: string): Promise<string | undefined> {
const result = localStorage.getItem(key)
if (result == undefined) {
return undefined
}
return result
}
async getAllRawStorageKeyValues() {
const results = []
for (const key of Object.keys(localStorage)) {
results.push({
key: key,
value: localStorage[key],
})
}
return results
}
async setRawStorageValue(key: string, value: string) {
localStorage.setItem(key, value)
}
async removeRawStorageValue(key: string) {
localStorage.removeItem(key)
}
async removeAllRawStorageValues() {
localStorage.clear()
}
async openDatabase(identifier: ApplicationIdentifier) {
this.databaseForIdentifier(identifier).unlock()
return new Promise((resolve, reject) => {
this.databaseForIdentifier(identifier)
.openDatabase(() => {
resolve({ isNewDatabase: true })
})
.then(() => {
resolve({ isNewDatabase: false })
})
.catch((error) => {
reject(error)
})
}) as Promise<{ isNewDatabase?: boolean } | undefined>
}
async getAllRawDatabasePayloads(identifier: ApplicationIdentifier) {
return this.databaseForIdentifier(identifier).getAllPayloads()
}
async saveRawDatabasePayload(payload: TransferPayload, identifier: ApplicationIdentifier) {
return this.databaseForIdentifier(identifier).savePayload(payload)
}
async saveRawDatabasePayloads(payloads: TransferPayload[], identifier: ApplicationIdentifier) {
return this.databaseForIdentifier(identifier).savePayloads(payloads)
}
async removeRawDatabasePayloadWithId(id: string, identifier: ApplicationIdentifier) {
return this.databaseForIdentifier(identifier).deletePayload(id)
}
async removeAllRawDatabasePayloads(identifier: ApplicationIdentifier) {
return this.databaseForIdentifier(identifier).clearAllPayloads()
}
async getNamespacedKeychainValue(identifier: ApplicationIdentifier) {
const keychain = await this.getKeychainValue()
if (!keychain) {
return
}
return keychain[identifier]
}
async getDatabaseKeys(): Promise<string[]> {
const keys: string[] = []
for (const database of this.databases) {
extendArray(keys, await database.getAllKeys())
}
return keys
}
async setNamespacedKeychainValue(value: NamespacedRootKeyInKeychain, identifier: ApplicationIdentifier) {
let keychain = await this.getKeychainValue()
if (!keychain) {
keychain = {}
}
return this.setKeychainValue({
...keychain,
[identifier]: value,
})
}
async clearNamespacedKeychainValue(identifier: ApplicationIdentifier) {
const keychain = await this.getKeychainValue()
if (!keychain) {
return
}
delete keychain[identifier]
return this.setKeychainValue(keychain)
}
setRawKeychainValue(value: unknown): Promise<void> {
return this.setKeychainValue(value)
}
openUrl(url: string) {
const win = window.open(url, '_blank')
if (win) {
win.focus()
}
}
setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void> {
return this.setKeychainValue(value)
}
abstract getKeychainValue(): Promise<RawKeychainValue>
abstract setKeychainValue(value: unknown): Promise<void>
abstract clearRawKeychainValue(): Promise<void>
abstract isDeviceDestroyed(): boolean
abstract performHardReset(): Promise<void>
async performSoftReset(): Promise<void> {
window.location.reload()
}
}

View File

@@ -0,0 +1,9 @@
export enum WebAppEvent {
NewUpdateAvailable = 'NewUpdateAvailable',
EditorFocused = 'EditorFocused',
BeganBackupDownload = 'BeganBackupDownload',
EndedBackupDownload = 'EndedBackupDownload',
PanelResized = 'PanelResized',
WindowDidFocus = 'WindowDidFocus',
WindowDidBlur = 'WindowDidBlur',
}