feat: mobile app package (#1075)
This commit is contained in:
339
packages/mobile/src/Lib/ComponentManager.ts
Normal file
339
packages/mobile/src/Lib/ComponentManager.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { MobileTheme } from '@Root/Style/MobileTheme'
|
||||
import FeatureChecksums from '@standardnotes/components/dist/checksums.json'
|
||||
import { FeatureDescription, FeatureIdentifier, GetFeatures } from '@standardnotes/features'
|
||||
import {
|
||||
ComponentMutator,
|
||||
EncryptionService,
|
||||
isRightVersionGreaterThanLeft,
|
||||
PermissionDialog,
|
||||
SNApplication,
|
||||
SNComponent,
|
||||
SNComponentManager,
|
||||
SNLog,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs'
|
||||
import { objectToCss } from '@Style/CssParser'
|
||||
import { Base64 } from 'js-base64'
|
||||
import RNFS, { DocumentDirectoryPath } from 'react-native-fs'
|
||||
import StaticServer from 'react-native-static-server'
|
||||
import { unzip } from 'react-native-zip-archive'
|
||||
import { MobileThemeContent } from '../Style/MobileTheme'
|
||||
|
||||
type TFeatureChecksums = {
|
||||
[key in FeatureIdentifier]: {
|
||||
version: string
|
||||
base64: string
|
||||
binary: string
|
||||
}
|
||||
}
|
||||
export enum ComponentLoadingError {
|
||||
FailedDownload = 'FailedDownload',
|
||||
ChecksumMismatch = 'ChecksumMismatch',
|
||||
LocalServerFailure = 'LocalServerFailure',
|
||||
DoesntExist = 'DoesntExist',
|
||||
Unknown = 'Unknown',
|
||||
}
|
||||
|
||||
const STATIC_SERVER_PORT = 8080
|
||||
const BASE_DOCUMENTS_PATH = DocumentDirectoryPath
|
||||
const COMPONENTS_PATH = '/components'
|
||||
|
||||
export class ComponentManager extends SNComponentManager {
|
||||
private mobileActiveTheme?: MobileTheme
|
||||
|
||||
private staticServer!: StaticServer
|
||||
private staticServerUrl!: string
|
||||
private protocolService!: EncryptionService
|
||||
private thirdPartyIndexPaths: Record<string, string> = {}
|
||||
|
||||
public async initialize(protocolService: EncryptionService) {
|
||||
this.loggingEnabled = false
|
||||
this.protocolService = protocolService
|
||||
await this.createServer()
|
||||
}
|
||||
|
||||
private async createServer() {
|
||||
const path = `${BASE_DOCUMENTS_PATH}${COMPONENTS_PATH}`
|
||||
const server = new StaticServer(STATIC_SERVER_PORT, path, {
|
||||
localOnly: true,
|
||||
})
|
||||
try {
|
||||
const serverUrl = await server.start()
|
||||
this.staticServer = server
|
||||
this.staticServerUrl = serverUrl
|
||||
} catch (e) {
|
||||
void this.alertService.alert(
|
||||
'Unable to start component server. ' +
|
||||
'Editors other than the Plain Editor will fail to load. ' +
|
||||
'Please restart the app and try again.',
|
||||
)
|
||||
SNLog.error(e as any)
|
||||
}
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
super.deinit()
|
||||
void this.staticServer!.stop()
|
||||
}
|
||||
|
||||
public isComponentDownloadable(component: SNComponent): boolean {
|
||||
const identifier = component.identifier
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
|
||||
return !!downloadUrl
|
||||
}
|
||||
|
||||
public async uninstallComponent(component: SNComponent) {
|
||||
const path = this.pathForComponent(component.identifier)
|
||||
if (await RNFS.exists(path)) {
|
||||
this.log('Deleting dir at', path)
|
||||
await RNFS.unlink(path)
|
||||
}
|
||||
}
|
||||
|
||||
public async doesComponentNeedDownload(component: SNComponent): Promise<boolean> {
|
||||
const identifier = component.identifier
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw Error('Attempting to download component with no download url')
|
||||
}
|
||||
|
||||
const version = nativeFeature?.version || component.package_info?.version
|
||||
|
||||
const existingPackageJson = await this.getDownloadedComponentPackageJsonFile(identifier)
|
||||
const existingVersion = existingPackageJson?.version
|
||||
this.log('Existing package version', existingVersion)
|
||||
this.log('Latest package version', version)
|
||||
|
||||
const shouldDownload = !existingPackageJson || isRightVersionGreaterThanLeft(existingVersion, version!)
|
||||
|
||||
return shouldDownload
|
||||
}
|
||||
|
||||
public async downloadComponentOffline(component: SNComponent): Promise<ComponentLoadingError | undefined> {
|
||||
const identifier = component.identifier
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw Error('Attempting to download component with no download url')
|
||||
}
|
||||
|
||||
let error
|
||||
try {
|
||||
error = await this.performDownloadComponent(identifier, downloadUrl)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return ComponentLoadingError.Unknown
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return error
|
||||
}
|
||||
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
if (!(await RNFS.exists(componentPath))) {
|
||||
this.log(`No component exists at path ${componentPath}, not using offline component`)
|
||||
return ComponentLoadingError.DoesntExist
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
public nativeFeatureForIdentifier(identifier: FeatureIdentifier) {
|
||||
return GetFeatures().find((feature: FeatureDescription) => feature.identifier === identifier)
|
||||
}
|
||||
|
||||
public isComponentThirdParty(identifier: FeatureIdentifier): boolean {
|
||||
return !this.nativeFeatureForIdentifier(identifier)
|
||||
}
|
||||
|
||||
public async preloadThirdPartyIndexPathFromDisk(identifier: FeatureIdentifier) {
|
||||
const packageJson = await this.getDownloadedComponentPackageJsonFile(identifier)
|
||||
this.thirdPartyIndexPaths[identifier] = packageJson?.sn?.main || 'index.html'
|
||||
}
|
||||
|
||||
private async passesChecksumValidation(filePath: string, featureIdentifier: FeatureIdentifier) {
|
||||
this.log('Performing checksum verification on', filePath)
|
||||
const zipContents = await RNFS.readFile(filePath, 'base64')
|
||||
const checksum = await this.protocolService.crypto.sha256(zipContents)
|
||||
|
||||
const desiredChecksum = (FeatureChecksums as TFeatureChecksums)[featureIdentifier]?.base64
|
||||
if (!desiredChecksum) {
|
||||
this.log(`Checksum is missing for ${featureIdentifier}; aborting installation`)
|
||||
return false
|
||||
}
|
||||
if (checksum !== desiredChecksum) {
|
||||
this.log(`Checksums don't match for ${featureIdentifier}; ${checksum} != ${desiredChecksum}; aborting install`)
|
||||
return false
|
||||
}
|
||||
this.log(`Checksum ${checksum} matches ${desiredChecksum} for ${featureIdentifier}`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async performDownloadComponent(
|
||||
identifier: FeatureIdentifier,
|
||||
downloadUrl: string,
|
||||
): Promise<ComponentLoadingError | undefined> {
|
||||
const tmpLocation = `${BASE_DOCUMENTS_PATH}/${identifier}.zip`
|
||||
|
||||
if (await RNFS.exists(tmpLocation)) {
|
||||
this.log('Deleting file at', tmpLocation)
|
||||
await RNFS.unlink(tmpLocation)
|
||||
}
|
||||
|
||||
this.log('Downloading component', identifier, 'from url', downloadUrl, 'to location', tmpLocation)
|
||||
|
||||
const result = await RNFS.downloadFile({
|
||||
fromUrl: downloadUrl,
|
||||
toFile: tmpLocation,
|
||||
}).promise
|
||||
|
||||
if (!String(result.statusCode).startsWith('2')) {
|
||||
console.error(`Error downloading file ${downloadUrl}`)
|
||||
return ComponentLoadingError.FailedDownload
|
||||
}
|
||||
|
||||
this.log('Finished download to tmp location', tmpLocation)
|
||||
|
||||
const requireChecksumVerification = !!this.nativeFeatureForIdentifier(identifier)
|
||||
if (requireChecksumVerification) {
|
||||
const passes = await this.passesChecksumValidation(tmpLocation, identifier)
|
||||
if (!passes) {
|
||||
return ComponentLoadingError.ChecksumMismatch
|
||||
}
|
||||
}
|
||||
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
|
||||
this.log(`Attempting to unzip ${tmpLocation} to ${componentPath}`)
|
||||
await unzip(tmpLocation, componentPath)
|
||||
this.log('Unzipped component to', componentPath)
|
||||
|
||||
const directoryContents = await RNFS.readDir(componentPath)
|
||||
const isNestedArchive = directoryContents.length === 1 && directoryContents[0].isDirectory()
|
||||
if (isNestedArchive) {
|
||||
this.log('Component download includes base level dir that is not its identifier, fixing...')
|
||||
const nestedDir = directoryContents[0]
|
||||
const tmpMovePath = `${BASE_DOCUMENTS_PATH}/${identifier}`
|
||||
await RNFS.moveFile(nestedDir.path, tmpMovePath)
|
||||
await RNFS.unlink(componentPath)
|
||||
await RNFS.moveFile(tmpMovePath, componentPath)
|
||||
this.log(`Moved directory from ${directoryContents[0].path} to ${componentPath}`)
|
||||
}
|
||||
await RNFS.unlink(tmpLocation)
|
||||
return
|
||||
}
|
||||
|
||||
private pathForComponent(identifier: FeatureIdentifier) {
|
||||
return `${BASE_DOCUMENTS_PATH}${COMPONENTS_PATH}/${identifier}`
|
||||
}
|
||||
|
||||
public async getFile(identifier: FeatureIdentifier, relativePath: string) {
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
if (!(await RNFS.exists(componentPath))) {
|
||||
return undefined
|
||||
}
|
||||
const filePath = `${componentPath}/${relativePath}`
|
||||
if (!(await RNFS.exists(filePath))) {
|
||||
return undefined
|
||||
}
|
||||
const fileContents = await RNFS.readFile(filePath)
|
||||
return fileContents
|
||||
}
|
||||
|
||||
public async getIndexFile(identifier: FeatureIdentifier) {
|
||||
if (this.isComponentThirdParty(identifier)) {
|
||||
await this.preloadThirdPartyIndexPathFromDisk(identifier)
|
||||
}
|
||||
const relativePath = this.getIndexFileRelativePath(identifier)
|
||||
return this.getFile(identifier, relativePath!)
|
||||
}
|
||||
|
||||
private async getDownloadedComponentPackageJsonFile(
|
||||
identifier: FeatureIdentifier,
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
const file = await this.getFile(identifier, 'package.json')
|
||||
if (!file) {
|
||||
return undefined
|
||||
}
|
||||
const packageJson = JSON.parse(file)
|
||||
return packageJson
|
||||
}
|
||||
|
||||
override async presentPermissionsDialog(dialog: PermissionDialog) {
|
||||
const text = `${dialog.component.name} would like to interact with your ${dialog.permissionsString}`
|
||||
const approved = await this.alertService.confirm(text, 'Grant Permissions', 'Continue', undefined, 'Cancel')
|
||||
dialog.callback(approved)
|
||||
}
|
||||
|
||||
private getIndexFileRelativePath(identifier: FeatureIdentifier) {
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
if (nativeFeature) {
|
||||
return nativeFeature.index_path
|
||||
} else {
|
||||
return this.thirdPartyIndexPaths[identifier]
|
||||
}
|
||||
}
|
||||
|
||||
override urlForComponent(component: SNComponent) {
|
||||
if (component.isTheme() && (component.content as MobileThemeContent).isSystemTheme) {
|
||||
const theme = component as MobileTheme
|
||||
const cssData = objectToCss(theme.mobileContent.variables)
|
||||
const encoded = Base64.encodeURI(cssData)
|
||||
return `data:text/css;base64,${encoded}`
|
||||
}
|
||||
|
||||
if (!this.isComponentDownloadable(component)) {
|
||||
return super.urlForComponent(component)
|
||||
}
|
||||
|
||||
const identifier = component.identifier
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
const indexFilePath = this.getIndexFileRelativePath(identifier)
|
||||
|
||||
if (!indexFilePath) {
|
||||
throw Error('Third party index path was not preloaded')
|
||||
}
|
||||
|
||||
const splitPackagePath = componentPath.split(COMPONENTS_PATH)
|
||||
const relativePackagePath = splitPackagePath[splitPackagePath.length - 1]
|
||||
const relativeMainFilePath = `${relativePackagePath}/${indexFilePath}`
|
||||
return `${this.staticServerUrl}${relativeMainFilePath}`
|
||||
}
|
||||
|
||||
public setMobileActiveTheme(theme: MobileTheme) {
|
||||
this.mobileActiveTheme = theme
|
||||
this.postActiveThemesToAllViewers()
|
||||
}
|
||||
|
||||
override getActiveThemes() {
|
||||
if (this.mobileActiveTheme) {
|
||||
return [this.mobileActiveTheme]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async preloadThirdPartyThemeIndexPath() {
|
||||
const theme = this.mobileActiveTheme
|
||||
if (!theme) {
|
||||
return
|
||||
}
|
||||
|
||||
const { identifier } = theme
|
||||
if (this.isComponentThirdParty(identifier)) {
|
||||
await this.preloadThirdPartyIndexPathFromDisk(identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function associateComponentWithNote(application: SNApplication, component: SNComponent, note: SNNote) {
|
||||
return application.mutator.changeItem<ComponentMutator>(component, mutator => {
|
||||
mutator.removeDisassociatedItemId(note.uuid)
|
||||
mutator.associateWithItem(note.uuid)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user