feat: add desktop repo (#1071)

This commit is contained in:
Mo
2022-06-07 11:52:15 -05:00
committed by GitHub
parent 0bb12db948
commit 0b7ce82aaa
135 changed files with 17821 additions and 180 deletions

View File

@@ -0,0 +1,245 @@
import { dialog, WebContents, shell } from 'electron'
import { promises as fs } from 'fs'
import path from 'path'
import { AppMessageType, MessageType } from '../../../../test/TestIpcMessage'
import { AppState } from '../../../application'
import { MessageToWebApp } from '../../Shared/IpcMessages'
import { BackupsManagerInterface } from './BackupsManagerInterface'
import {
deleteDir,
deleteDirContents,
ensureDirectoryExists,
FileDoesNotExist,
moveFiles,
openDirectoryPicker,
} from '../Utils/FileUtils'
import { Paths } from '../Types/Paths'
import { StoreKeys } from '../Store'
import { backups as str } from '../Strings'
import { handleTestMessage, send } from '../Utils/Testing'
import { isTesting, last } from '../Utils/Utils'
function log(...message: any) {
console.log('BackupsManager:', ...message)
}
function logError(...message: any) {
console.error('BackupsManager:', ...message)
}
export const enum EnsureRecentBackupExists {
Success = 0,
BackupsAreDisabled = 1,
FailedToCreateBackup = 2,
}
export const BackupsDirectoryName = 'Standard Notes Backups'
const BackupFileExtension = '.txt'
function backupFileNameToDate(string: string): number {
string = path.basename(string, '.txt')
const dateTimeDelimiter = string.indexOf('T')
const date = string.slice(0, dateTimeDelimiter)
const time = string.slice(dateTimeDelimiter + 1).replace(/-/g, ':')
return Date.parse(date + 'T' + time)
}
function dateToSafeFilename(date: Date) {
return date.toISOString().replace(/:/g, '-')
}
async function copyDecryptScript(location: string) {
try {
await ensureDirectoryExists(location)
await fs.copyFile(Paths.decryptScript, path.join(location, path.basename(Paths.decryptScript)))
} catch (error) {
console.error(error)
}
}
export function createBackupsManager(webContents: WebContents, appState: AppState): BackupsManagerInterface {
let backupsLocation = appState.store.get(StoreKeys.BackupsLocation)
let backupsDisabled = appState.store.get(StoreKeys.BackupsDisabled)
let needsBackup = false
if (!backupsDisabled) {
void copyDecryptScript(backupsLocation)
}
determineLastBackupDate(backupsLocation)
.then((date) => appState.setBackupCreationDate(date))
.catch(console.error)
async function setBackupsLocation(location: string) {
const previousLocation = backupsLocation
if (previousLocation === location) {
return
}
const newLocation = path.join(location, BackupsDirectoryName)
let previousLocationFiles = await fs.readdir(previousLocation)
const backupFiles = previousLocationFiles
.filter((fileName) => fileName.endsWith(BackupFileExtension))
.map((fileName) => path.join(previousLocation, fileName))
await moveFiles(backupFiles, newLocation)
await copyDecryptScript(newLocation)
previousLocationFiles = await fs.readdir(previousLocation)
if (previousLocationFiles.length === 0 || previousLocationFiles[0] === path.basename(Paths.decryptScript)) {
await deleteDir(previousLocation)
}
/** Wait for the operation to be successful before saving new location */
backupsLocation = newLocation
appState.store.set(StoreKeys.BackupsLocation, backupsLocation)
}
async function saveBackupData(data: any) {
if (backupsDisabled) {
return
}
let success: boolean
let name: string | undefined
try {
name = await writeDataToFile(data)
log(`Data backup successfully saved: ${name}`)
success = true
appState.setBackupCreationDate(Date.now())
} catch (err) {
success = false
logError('An error occurred saving backup file', err)
}
webContents.send(MessageToWebApp.FinishedSavingBackup, { success })
if (isTesting()) {
send(AppMessageType.SavedBackup)
}
return name
}
function performBackup() {
if (backupsDisabled) {
return
}
webContents.send(MessageToWebApp.PerformAutomatedBackup)
}
async function writeDataToFile(data: any): Promise<string> {
await ensureDirectoryExists(backupsLocation)
const name = dateToSafeFilename(new Date()) + BackupFileExtension
const filePath = path.join(backupsLocation, name)
await fs.writeFile(filePath, data)
return name
}
let interval: NodeJS.Timeout | undefined
function beginBackups() {
if (interval) {
clearInterval(interval)
}
needsBackup = true
const hoursInterval = 12
const seconds = hoursInterval * 60 * 60
const milliseconds = seconds * 1000
interval = setInterval(performBackup, milliseconds)
}
function toggleBackupsStatus() {
backupsDisabled = !backupsDisabled
appState.store.set(StoreKeys.BackupsDisabled, backupsDisabled)
/** Create a backup on reactivation. */
if (!backupsDisabled) {
performBackup()
}
}
if (isTesting()) {
handleTestMessage(MessageType.DataArchive, (data: any) => saveBackupData(data))
handleTestMessage(MessageType.BackupsAreEnabled, () => !backupsDisabled)
handleTestMessage(MessageType.ToggleBackupsEnabled, toggleBackupsStatus)
handleTestMessage(MessageType.BackupsLocation, () => backupsLocation)
handleTestMessage(MessageType.PerformBackup, performBackup)
handleTestMessage(MessageType.CopyDecryptScript, copyDecryptScript)
handleTestMessage(MessageType.ChangeBackupsLocation, setBackupsLocation)
}
return {
get backupsAreEnabled() {
return !backupsDisabled
},
get backupsLocation() {
return backupsLocation
},
saveBackupData,
performBackup,
beginBackups,
toggleBackupsStatus,
async backupsCount(): Promise<number> {
let files = await fs.readdir(backupsLocation)
files = files.filter((fileName) => fileName.endsWith(BackupFileExtension))
return files.length
},
applicationDidBlur() {
if (needsBackup) {
needsBackup = false
performBackup()
}
},
viewBackups() {
void shell.openPath(backupsLocation)
},
async deleteBackups() {
await deleteDirContents(backupsLocation)
return copyDecryptScript(backupsLocation)
},
async changeBackupsLocation() {
const path = await openDirectoryPicker()
if (!path) {
return
}
try {
await setBackupsLocation(path)
performBackup()
} catch (e) {
logError(e)
void dialog.showMessageBox({
message: str().errorChangingDirectory(e),
})
}
},
}
}
async function determineLastBackupDate(backupsLocation: string): Promise<number | null> {
try {
const files = (await fs.readdir(backupsLocation))
.filter((filename) => filename.endsWith(BackupFileExtension) && !Number.isNaN(backupFileNameToDate(filename)))
.sort()
const lastBackupFileName = last(files)
if (!lastBackupFileName) {
return null
}
const backupDate = backupFileNameToDate(lastBackupFileName)
if (Number.isNaN(backupDate)) {
return null
}
return backupDate
} catch (error: any) {
if (error.code !== FileDoesNotExist) {
console.error(error)
}
return null
}
}

View File

@@ -0,0 +1,13 @@
export interface BackupsManagerInterface {
backupsAreEnabled: boolean
toggleBackupsStatus(): void
backupsLocation: string
backupsCount(): Promise<number>
applicationDidBlur(): void
changeBackupsLocation(): void
beginBackups(): void
performBackup(): void
deleteBackups(): Promise<void>
viewBackups(): void
saveBackupData(data: unknown): void
}

View File

@@ -0,0 +1,115 @@
import fs from 'fs'
import http, { IncomingMessage, ServerResponse } from 'http'
import mime from 'mime-types'
import path from 'path'
import { URL } from 'url'
import { FileDoesNotExist } from './Utils/FileUtils'
import { Paths } from './Types/Paths'
import { extensions as str } from './Strings'
const Protocol = 'http'
function logError(...message: any) {
console.error('extServer:', ...message)
}
function log(...message: any) {
console.log('extServer:', ...message)
}
export function normalizeFilePath(requestUrl: string, host: string): string {
const isThirdPartyComponent = requestUrl.startsWith('/Extensions')
const isNativeComponent = requestUrl.startsWith('/components')
if (!isThirdPartyComponent && !isNativeComponent) {
throw new Error(`URL '${requestUrl}' falls outside of the extensions/features domain.`)
}
const removedPrefix = requestUrl.replace('/components', '').replace('/Extensions', '')
const base = `${Protocol}://${host}`
const url = new URL(removedPrefix, base)
/**
* Normalize path (parse '..' and '.') so that we prevent path traversal by
* joining a fully resolved path to the Extensions dir.
*/
const modifiedReqUrl = path.normalize(url.pathname)
if (isThirdPartyComponent) {
return path.join(Paths.extensionsDir, modifiedReqUrl)
} else {
return path.join(Paths.components, modifiedReqUrl)
}
}
async function handleRequest(request: IncomingMessage, response: ServerResponse) {
try {
if (!request.url) {
throw new Error('No url.')
}
if (!request.headers.host) {
throw new Error('No `host` header.')
}
const filePath = normalizeFilePath(request.url, request.headers.host)
const stat = await fs.promises.lstat(filePath)
if (!stat.isFile()) {
throw new Error('Client requested something that is not a file.')
}
const mimeType = mime.lookup(path.parse(filePath).ext)
response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Cache-Control', 'max-age=604800')
response.setHeader('Content-Type', `${mimeType}; charset=utf-8`)
const data = fs.readFileSync(filePath)
response.writeHead(200)
response.end(data)
} catch (error: any) {
onRequestError(error, response)
}
}
function onRequestError(error: Error | { code: string }, response: ServerResponse) {
let responseCode: number
let message: string
if ('code' in error && error.code === FileDoesNotExist) {
responseCode = 404
message = str().missingExtension
} else {
logError(error)
responseCode = 500
message = str().unableToLoadExtension
}
response.writeHead(responseCode)
response.end(message)
}
export function createExtensionsServer(): string {
const port = 45653
const ip = '127.0.0.1'
const host = `${Protocol}://${ip}:${port}`
const initCallback = () => {
log(`Server started at ${host}`)
}
try {
http
.createServer(handleRequest)
.listen(port, ip, initCallback)
.on('error', (err) => {
console.error('Error listening on extServer', err)
})
} catch (error) {
console.error('Error creating ext server', error)
}
return host
}

View File

@@ -0,0 +1,156 @@
import { FileBackupsDevice, FileBackupsMapping } from '@web/Application/Device/DesktopSnjsExports'
import { AppState } from 'app/application'
import { StoreKeys } from '../Store'
import {
ensureDirectoryExists,
moveDirContents,
openDirectoryPicker,
readJSONFile,
writeFile,
writeJSONFile,
} from '../Utils/FileUtils'
import { FileDownloader } from './FileDownloader'
import { shell } from 'electron'
export const FileBackupsConstantsV1 = {
Version: '1.0.0',
MetadataFileName: 'metadata.sn.json',
BinaryFileName: 'file.encrypted',
}
export class FilesBackupManager implements FileBackupsDevice {
constructor(private appState: AppState) {}
public isFilesBackupsEnabled(): Promise<boolean> {
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsEnabled))
}
public async enableFilesBackups(): Promise<void> {
const currentLocation = await this.getFilesBackupsLocation()
if (!currentLocation) {
const result = await this.changeFilesBackupsLocation()
if (!result) {
return
}
}
this.appState.store.set(StoreKeys.FileBackupsEnabled, true)
const mapping = this.getMappingFileFromDisk()
if (!mapping) {
await this.saveFilesBackupsMappingFile(this.defaultMappingFileValue())
}
}
public disableFilesBackups(): Promise<void> {
this.appState.store.set(StoreKeys.FileBackupsEnabled, false)
return Promise.resolve()
}
public async changeFilesBackupsLocation(): Promise<string | undefined> {
const newPath = await openDirectoryPicker()
if (!newPath) {
return undefined
}
const oldPath = await this.getFilesBackupsLocation()
if (oldPath) {
await moveDirContents(oldPath, newPath)
}
this.appState.store.set(StoreKeys.FileBackupsLocation, newPath)
return newPath
}
public getFilesBackupsLocation(): Promise<string> {
return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsLocation))
}
private getMappingFileLocation(): string {
const base = this.appState.store.get(StoreKeys.FileBackupsLocation)
return `${base}/info.json`
}
private async getMappingFileFromDisk(): Promise<FileBackupsMapping | undefined> {
return readJSONFile<FileBackupsMapping>(this.getMappingFileLocation())
}
private defaultMappingFileValue(): FileBackupsMapping {
return { version: FileBackupsConstantsV1.Version, files: {} }
}
async getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
const data = await this.getMappingFileFromDisk()
if (!data) {
return this.defaultMappingFileValue()
}
return data
}
async openFilesBackupsLocation(): Promise<void> {
const location = await this.getFilesBackupsLocation()
void shell.openPath(location)
}
async saveFilesBackupsMappingFile(file: FileBackupsMapping): Promise<'success' | 'failed'> {
await writeJSONFile(this.getMappingFileLocation(), file)
return 'success'
}
async saveFilesBackupsFile(
uuid: string,
metaFile: string,
downloadRequest: {
chunkSizes: number[]
valetToken: string
url: string
},
): Promise<'success' | 'failed'> {
const backupsDir = await this.getFilesBackupsLocation()
const fileDir = `${backupsDir}/${uuid}`
const metaFilePath = `${fileDir}/${FileBackupsConstantsV1.MetadataFileName}`
const binaryPath = `${fileDir}/${FileBackupsConstantsV1.BinaryFileName}`
await ensureDirectoryExists(fileDir)
await writeFile(metaFilePath, metaFile)
const downloader = new FileDownloader(
downloadRequest.chunkSizes,
downloadRequest.valetToken,
downloadRequest.url,
binaryPath,
)
const result = await downloader.run()
if (result === 'success') {
const mapping = await this.getFilesBackupsMappingFile()
mapping.files[uuid] = {
backedUpOn: new Date(),
absolutePath: fileDir,
relativePath: uuid,
metadataFileName: FileBackupsConstantsV1.MetadataFileName,
binaryFileName: FileBackupsConstantsV1.BinaryFileName,
version: FileBackupsConstantsV1.Version,
}
await this.saveFilesBackupsMappingFile(mapping)
}
return result
}
}

View File

@@ -0,0 +1,54 @@
import { WriteStream, createWriteStream } from 'fs'
import { downloadData } from './FileNetworking'
export class FileDownloader {
writeStream: WriteStream
constructor(private chunkSizes: number[], private valetToken: string, private url: string, filePath: string) {
this.writeStream = createWriteStream(filePath, { flags: 'a' })
}
public async run(): Promise<'success' | 'failed'> {
const result = await this.downloadChunk(0, 0)
this.writeStream.close()
return result
}
private async downloadChunk(chunkIndex = 0, contentRangeStart: number): Promise<'success' | 'failed'> {
const pullChunkSize = this.chunkSizes[chunkIndex]
const headers = {
'x-valet-token': this.valetToken,
'x-chunk-size': pullChunkSize.toString(),
range: `bytes=${contentRangeStart}-`,
}
const response = await downloadData(this.writeStream, this.url, headers)
if (!String(response.status).startsWith('2')) {
return 'failed'
}
const contentRangeHeader = response.headers['content-range'] as string
if (!contentRangeHeader) {
return 'failed'
}
const matches = contentRangeHeader.match(/(^[a-zA-Z][\w]*)\s+(\d+)\s?-\s?(\d+)?\s?\/?\s?(\d+|\*)?/)
if (!matches || matches.length !== 5) {
return 'failed'
}
const rangeStart = +matches[2]
const rangeEnd = +matches[3]
const totalSize = +matches[4]
if (rangeEnd < totalSize - 1) {
return this.downloadChunk(++chunkIndex, rangeStart + pullChunkSize)
}
return 'success'
}
}

View File

@@ -0,0 +1,22 @@
import { WriteStream } from 'fs'
import axios, { AxiosResponseHeaders, AxiosRequestHeaders } from 'axios'
export async function downloadData(
writeStream: WriteStream,
url: string,
headers: AxiosRequestHeaders,
): Promise<{
headers: AxiosResponseHeaders
status: number
}> {
const response = await axios.get(url, {
responseType: 'arraybuffer',
headers: headers,
})
if (String(response.status).startsWith('2')) {
writeStream.write(response.data)
}
return { headers: response.headers, status: response.status }
}

View File

@@ -0,0 +1,104 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import keytar from 'keytar'
import { isLinux } from '../Types/Platforms'
import { AppName } from '../Strings'
import { keychainAccessIsUserConfigurable } from '../Types/Constants'
import { isDev, isTesting } from '../Utils/Utils'
import { MessageToMainProcess } from '../../Shared/IpcMessages'
import { Urls, Paths } from '../Types/Paths'
import { Store, StoreKeys } from '../Store'
import { KeychainInterface } from './KeychainInterface'
const ServiceName = isTesting() ? AppName + ' (Testing)' : isDev() ? AppName + ' (Development)' : AppName
const AccountName = 'Standard Notes Account'
async function ensureKeychainAccess(store: Store): Promise<BrowserWindow | undefined> {
if (!isLinux()) {
/** Assume keychain is accessible */
return
}
const useNativeKeychain = store.get(StoreKeys.UseNativeKeychain)
if (useNativeKeychain === null) {
/**
* App has never attempted to access keychain before. Do it and set the
* store value according to what happens
*/
try {
await getKeychainValue()
store.set(StoreKeys.UseNativeKeychain, true)
} catch (_) {
/** Can't access keychain. */
if (keychainAccessIsUserConfigurable) {
return askForKeychainAccess(store)
} else {
/** User can't configure keychain access, fall back to local storage */
store.set(StoreKeys.UseNativeKeychain, false)
}
}
}
}
function askForKeychainAccess(store: Store): Promise<BrowserWindow> {
const window = new BrowserWindow({
width: 540,
height: 400,
center: true,
show: false,
webPreferences: {
preload: Paths.grantLinuxPasswordsAccessJs,
nodeIntegration: false,
contextIsolation: true,
},
autoHideMenuBar: true,
})
window.on('ready-to-show', window.show)
void window.loadURL(Urls.grantLinuxPasswordsAccessHtml)
const quit = () => {
app.quit()
}
ipcMain.once(MessageToMainProcess.Quit, quit)
window.once('close', quit)
ipcMain.on(MessageToMainProcess.LearnMoreAboutKeychainAccess, () => {
window.setSize(window.getSize()[0], 600, true)
})
return new Promise((resolve) => {
ipcMain.once(MessageToMainProcess.UseLocalstorageForKeychain, () => {
store.set(StoreKeys.UseNativeKeychain, false)
ipcMain.removeListener(MessageToMainProcess.Quit, quit)
window.removeListener('close', quit)
resolve(window)
})
})
}
async function getKeychainValue(): Promise<unknown> {
try {
const value = await keytar.getPassword(ServiceName, AccountName)
if (value) {
return JSON.parse(value)
}
} catch (error) {
console.error(error)
throw error
}
}
function setKeychainValue(value: unknown): Promise<void> {
return keytar.setPassword(ServiceName, AccountName, JSON.stringify(value))
}
function clearKeychainValue(): Promise<boolean> {
return keytar.deletePassword(ServiceName, AccountName)
}
export const Keychain: KeychainInterface = {
ensureKeychainAccess,
getKeychainValue,
setKeychainValue,
clearKeychainValue,
}

View File

@@ -0,0 +1,9 @@
import { BrowserWindow } from 'electron'
import { Store } from '../Store'
export interface KeychainInterface {
ensureKeychainAccess(store: Store): Promise<BrowserWindow | undefined>
getKeychainValue(): Promise<unknown>
setKeychainValue(value: unknown): Promise<void>
clearKeychainValue(): Promise<boolean>
}

View File

@@ -0,0 +1,4 @@
export interface MenuManagerInterface {
reload(): void
popupMenu(): void
}

View File

@@ -0,0 +1,667 @@
import { AppState } from 'app/application'
import {
app,
BrowserWindow,
ContextMenuParams,
dialog,
Menu,
MenuItemConstructorOptions,
shell,
WebContents,
} from 'electron'
import { autorun } from 'mobx'
import { autoUpdatingAvailable } from '../Types/Constants'
import { isLinux, isMac } from '../Types/Platforms'
import { Store, StoreKeys } from '../Store'
import { appMenu as str, contextMenu } from '../Strings'
import { handleTestMessage } from '../Utils/Testing'
import { TrayManager } from '../TrayManager'
import { SpellcheckerManager } from './../SpellcheckerManager'
import { BackupsManagerInterface } from './../Backups/BackupsManagerInterface'
import { MessageType } from './../../../../test/TestIpcMessage'
import { checkForUpdate, openChangelog, showUpdateInstallationDialog } from '../UpdateManager'
import { isDev, isTesting } from '../Utils/Utils'
import { MenuManagerInterface } from './MenuManagerInterface'
export const enum MenuId {
SpellcheckerLanguages = 'SpellcheckerLanguages',
}
const Separator: MenuItemConstructorOptions = {
type: 'separator',
}
export function buildContextMenu(webContents: WebContents, params: ContextMenuParams): Menu {
if (!params.isEditable) {
return Menu.buildFromTemplate([
{
role: 'copy',
},
])
}
return Menu.buildFromTemplate([
...suggestionsMenu(params.selectionText, params.misspelledWord, params.dictionarySuggestions, webContents),
Separator,
{
role: 'undo',
},
{
role: 'redo',
},
Separator,
{
role: 'cut',
},
{
role: 'copy',
},
{
role: 'paste',
},
{
role: 'pasteAndMatchStyle',
},
{
role: 'selectAll',
},
])
}
function suggestionsMenu(
selection: string,
misspelledWord: string,
suggestions: string[],
webContents: WebContents,
): MenuItemConstructorOptions[] {
if (misspelledWord.length === 0) {
return []
}
const learnSpelling = {
label: contextMenu().learnSpelling,
click() {
webContents.session.addWordToSpellCheckerDictionary(misspelledWord)
},
}
if (suggestions.length === 0) {
return [
{
label: contextMenu().noSuggestions,
enabled: false,
},
Separator,
learnSpelling,
]
}
return [
...suggestions.map((suggestion) => ({
label: suggestion,
click() {
webContents.replaceMisspelling(suggestion)
},
})),
Separator,
learnSpelling,
]
}
export function createMenuManager({
window,
appState,
backupsManager,
trayManager,
store,
spellcheckerManager,
}: {
window: Electron.BrowserWindow
appState: AppState
backupsManager: BackupsManagerInterface
trayManager: TrayManager
store: Store
spellcheckerManager?: SpellcheckerManager
}): MenuManagerInterface {
let menu: Menu
if (isTesting()) {
// eslint-disable-next-line no-var
var hasReloaded = false
// eslint-disable-next-line no-var
var hasReloadedTimeout: any
handleTestMessage(MessageType.AppMenuItems, () =>
menu.items.map((item) => ({
label: item.label,
role: item.role,
submenu: {
items: item.submenu?.items?.map((subItem) => ({
id: subItem.id,
label: subItem.label,
})),
},
})),
)
handleTestMessage(MessageType.ClickLanguage, (code) => {
menu.getMenuItemById(MessageType.ClickLanguage + code)!.click()
})
handleTestMessage(MessageType.HasReloadedMenu, () => hasReloaded)
}
function reload() {
if (isTesting()) {
// eslint-disable-next-line block-scoped-var
hasReloaded = true
// eslint-disable-next-line block-scoped-var
clearTimeout(hasReloadedTimeout)
// eslint-disable-next-line block-scoped-var
hasReloadedTimeout = setTimeout(() => {
// eslint-disable-next-line block-scoped-var
hasReloaded = false
}, 300)
}
menu = Menu.buildFromTemplate([
...(isMac() ? [macAppMenu(app.name)] : []),
editMenu(spellcheckerManager, reload),
viewMenu(window, store, reload),
windowMenu(store, trayManager, reload),
backupsMenu(backupsManager, reload),
updateMenu(window, appState),
...(isLinux() ? [keyringMenu(window, store)] : []),
helpMenu(window, shell),
])
Menu.setApplicationMenu(menu)
}
autorun(() => {
reload()
})
return {
reload,
popupMenu() {
if (isDev()) {
/** Check the state */
if (!menu) {
throw new Error('called popupMenu() before loading')
}
}
// eslint-disable-next-line no-unused-expressions
menu?.popup()
},
}
}
const enum Roles {
Undo = 'undo',
Redo = 'redo',
Cut = 'cut',
Copy = 'copy',
Paste = 'paste',
PasteAndMatchStyle = 'pasteAndMatchStyle',
SelectAll = 'selectAll',
Reload = 'reload',
ToggleDevTools = 'toggleDevTools',
ResetZoom = 'resetZoom',
ZoomIn = 'zoomIn',
ZoomOut = 'zoomOut',
ToggleFullScreen = 'togglefullscreen',
Window = 'window',
Minimize = 'minimize',
Close = 'close',
Help = 'help',
About = 'about',
Services = 'services',
Hide = 'hide',
HideOthers = 'hideOthers',
UnHide = 'unhide',
Quit = 'quit',
StartSeeking = 'startSpeaking',
StopSeeking = 'stopSpeaking',
Zoom = 'zoom',
Front = 'front',
}
const KeyCombinations = {
CmdOrCtrlW: 'CmdOrCtrl + W',
CmdOrCtrlM: 'CmdOrCtrl + M',
AltM: 'Alt + m',
}
const enum MenuItemTypes {
CheckBox = 'checkbox',
Radio = 'radio',
}
const Urls = {
Support: 'mailto:help@standardnotes.com',
Website: 'https://standardnotes.com',
GitHub: 'https://github.com/standardnotes',
Slack: 'https://standardnotes.com/slack',
Twitter: 'https://twitter.com/StandardNotes',
GitHubReleases: 'https://github.com/standardnotes/desktop/releases',
}
function macAppMenu(appName: string): MenuItemConstructorOptions {
return {
role: 'appMenu',
label: appName,
submenu: [
{
role: Roles.About,
},
Separator,
{
role: Roles.Services,
submenu: [],
},
Separator,
{
role: Roles.Hide,
},
{
role: Roles.HideOthers,
},
{
role: Roles.UnHide,
},
Separator,
{
role: Roles.Quit,
},
],
}
}
function editMenu(spellcheckerManager: SpellcheckerManager | undefined, reload: () => any): MenuItemConstructorOptions {
if (isDev()) {
/** Check for invalid state */
if (!isMac() && spellcheckerManager === undefined) {
throw new Error('spellcheckerManager === undefined')
}
}
return {
role: 'editMenu',
label: str().edit,
submenu: [
{
role: Roles.Undo,
},
{
role: Roles.Redo,
},
Separator,
{
role: Roles.Cut,
},
{
role: Roles.Copy,
},
{
role: Roles.Paste,
},
{
role: Roles.PasteAndMatchStyle,
},
{
role: Roles.SelectAll,
},
...(isMac() ? [Separator, macSpeechMenu()] : [spellcheckerMenu(spellcheckerManager!, reload)]),
],
}
}
function macSpeechMenu(): MenuItemConstructorOptions {
return {
label: str().speech,
submenu: [
{
role: Roles.StopSeeking,
},
{
role: Roles.StopSeeking,
},
],
}
}
function spellcheckerMenu(spellcheckerManager: SpellcheckerManager, reload: () => any): MenuItemConstructorOptions {
return {
id: MenuId.SpellcheckerLanguages,
label: str().spellcheckerLanguages,
submenu: spellcheckerManager.languages().map(
({ name, code, enabled }): MenuItemConstructorOptions => ({
...(isTesting() ? { id: MessageType.ClickLanguage + code } : {}),
label: name,
type: MenuItemTypes.CheckBox,
checked: enabled,
click: () => {
if (enabled) {
spellcheckerManager.removeLanguage(code)
} else {
spellcheckerManager.addLanguage(code)
}
reload()
},
}),
),
}
}
function viewMenu(window: Electron.BrowserWindow, store: Store, reload: () => any): MenuItemConstructorOptions {
return {
label: str().view,
submenu: [
{
role: Roles.Reload,
},
{
role: Roles.ToggleDevTools,
},
Separator,
{
role: Roles.ResetZoom,
},
{
role: Roles.ZoomIn,
},
{
role: Roles.ZoomOut,
},
Separator,
{
role: Roles.ToggleFullScreen,
},
...(isMac() ? [] : [Separator, ...menuBarOptions(window, store, reload)]),
],
}
}
function menuBarOptions(window: Electron.BrowserWindow, store: Store, reload: () => any) {
const useSystemMenuBar = store.get(StoreKeys.UseSystemMenuBar)
let isMenuBarVisible = store.get(StoreKeys.MenuBarVisible)
window.setMenuBarVisibility(isMenuBarVisible)
return [
{
visible: !isMac() && useSystemMenuBar,
label: str().hideMenuBar,
accelerator: KeyCombinations.AltM,
click: () => {
isMenuBarVisible = !isMenuBarVisible
window.setMenuBarVisibility(isMenuBarVisible)
store.set(StoreKeys.MenuBarVisible, isMenuBarVisible)
},
},
{
label: str().useThemedMenuBar,
type: MenuItemTypes.CheckBox,
checked: !useSystemMenuBar,
click: () => {
store.set(StoreKeys.UseSystemMenuBar, !useSystemMenuBar)
reload()
void dialog.showMessageBox({
title: str().preferencesChanged.title,
message: str().preferencesChanged.message,
})
},
},
]
}
function windowMenu(store: Store, trayManager: TrayManager, reload: () => any): MenuItemConstructorOptions {
return {
role: Roles.Window,
submenu: [
{
role: Roles.Minimize,
},
{
role: Roles.Close,
},
Separator,
...(isMac() ? macWindowItems() : [minimizeToTrayItem(store, trayManager, reload)]),
],
}
}
function macWindowItems(): MenuItemConstructorOptions[] {
return [
{
label: str().close,
accelerator: KeyCombinations.CmdOrCtrlW,
role: Roles.Close,
},
{
label: str().minimize,
accelerator: KeyCombinations.CmdOrCtrlM,
role: Roles.Minimize,
},
{
label: str().zoom,
role: Roles.Zoom,
},
Separator,
{
label: str().bringAllToFront,
role: Roles.Front,
},
]
}
function minimizeToTrayItem(store: Store, trayManager: TrayManager, reload: () => any) {
const minimizeToTray = trayManager.shouldMinimizeToTray()
return {
label: str().minimizeToTrayOnClose,
type: MenuItemTypes.CheckBox,
checked: minimizeToTray,
click() {
store.set(StoreKeys.MinimizeToTray, !minimizeToTray)
if (trayManager.shouldMinimizeToTray()) {
trayManager.createTrayIcon()
} else {
trayManager.destroyTrayIcon()
}
reload()
},
}
}
function backupsMenu(archiveManager: BackupsManagerInterface, reload: () => any) {
return {
label: str().backups,
submenu: [
{
label: archiveManager.backupsAreEnabled ? str().disableAutomaticBackups : str().enableAutomaticBackups,
click() {
archiveManager.toggleBackupsStatus()
reload()
},
},
Separator,
{
label: str().changeBackupsLocation,
click() {
archiveManager.changeBackupsLocation()
},
},
{
label: str().openBackupsLocation,
click() {
void shell.openPath(archiveManager.backupsLocation)
},
},
],
}
}
function updateMenu(window: BrowserWindow, appState: AppState) {
const updateState = appState.updates
let label
if (updateState.checkingForUpdate) {
label = str().checkingForUpdate
} else if (updateState.updateNeeded) {
label = str().updateAvailable
} else {
label = str().updates
}
const submenu: MenuItemConstructorOptions[] = []
const structure = { label, submenu }
if (autoUpdatingAvailable) {
if (updateState.autoUpdateDownloaded && updateState.latestVersion) {
submenu.push({
label: str().installPendingUpdate(updateState.latestVersion),
click() {
void showUpdateInstallationDialog(window, appState)
},
})
}
submenu.push({
type: 'checkbox',
checked: updateState.enableAutoUpdate,
label: str().enableAutomaticUpdates,
click() {
updateState.toggleAutoUpdate()
},
})
submenu.push(Separator)
}
const latestVersion = updateState.latestVersion
submenu.push({
label: str().yourVersion(appState.version),
})
submenu.push({
label: latestVersion ? str().latestVersion(latestVersion) : str().releaseNotes,
click() {
openChangelog(updateState)
},
})
if (latestVersion) {
submenu.push({
label: str().viewReleaseNotes(latestVersion),
click() {
openChangelog(updateState)
},
})
}
if (autoUpdatingAvailable) {
submenu.push(Separator)
if (!updateState.checkingForUpdate) {
submenu.push({
label: str().checkForUpdate,
click() {
void checkForUpdate(appState, updateState, true)
},
})
}
if (updateState.lastCheck && !updateState.checkingForUpdate) {
submenu.push({
label: str().lastUpdateCheck(updateState.lastCheck),
})
}
}
return structure
}
function helpMenu(window: Electron.BrowserWindow, shell: Electron.Shell) {
return {
role: Roles.Help,
submenu: [
{
label: str().emailSupport,
click() {
void shell.openExternal(Urls.Support)
},
},
{
label: str().website,
click() {
void shell.openExternal(Urls.Website)
},
},
{
label: str().gitHub,
click() {
void shell.openExternal(Urls.GitHub)
},
},
{
label: str().slack,
click() {
void shell.openExternal(Urls.Slack)
},
},
{
label: str().twitter,
click() {
void shell.openExternal(Urls.Twitter)
},
},
Separator,
{
label: str().toggleErrorConsole,
click() {
window.webContents.toggleDevTools()
},
},
{
label: str().openDataDirectory,
click() {
const userDataPath = app.getPath('userData')
void shell.openPath(userDataPath)
},
},
{
label: str().clearCacheAndReload,
async click() {
await window.webContents.session.clearCache()
window.reload()
},
},
Separator,
{
label: str().version(app.getVersion()),
click() {
void shell.openExternal(Urls.GitHubReleases)
},
},
],
}
}
/** It's called keyring on Ubuntu */
function keyringMenu(window: BrowserWindow, store: Store): MenuItemConstructorOptions {
const useNativeKeychain = store.get(StoreKeys.UseNativeKeychain)
return {
label: str().security.security,
submenu: [
{
enabled: !useNativeKeychain,
checked: useNativeKeychain ?? false,
type: 'checkbox',
label: str().security.useKeyringtoStorePassword,
async click() {
store.set(StoreKeys.UseNativeKeychain, true)
const { response } = await dialog.showMessageBox(window, {
message: str().security.enabledKeyringAccessMessage,
buttons: [str().security.enabledKeyringQuitNow, str().security.enabledKeyringPostpone],
})
if (response === 0) {
app.quit()
}
},
},
],
}
}

View File

@@ -0,0 +1,75 @@
import { IncomingMessage, net } from 'electron'
import fs from 'fs'
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'
const pipeline = promisify(pipelineFn)
if (isTesting()) {
handleTestMessage(MessageType.GetJSON, getJSON)
handleTestMessage(MessageType.DownloadFile, downloadFile)
}
/**
* Downloads a file to the specified destination.
* @param filePath path to the saved file (will be created if it does
* not exist)
*/
export async function downloadFile(url: string, filePath: string): Promise<void> {
await ensureDirectoryExists(path.dirname(filePath))
const response = await get(url)
await pipeline(
/**
* IncomingMessage doesn't implement *every* property of ReadableStream
* but still all the ones that pipeline needs
* @see https://www.electronjs.org/docs/api/incoming-message
*/
response as any,
fs.createWriteStream(filePath),
)
}
export async function getJSON<T>(url: string): Promise<T | undefined> {
const response = await get(url)
let data = ''
return new Promise((resolve, reject) => {
response
.on('data', (chunk) => {
data += chunk
})
.on('error', reject)
.on('end', () => {
try {
const parsed = JSON.parse(data)
resolve(parsed)
} catch (error) {
resolve(undefined)
}
})
})
}
export function get(url: string): Promise<IncomingMessage> {
const enum Method {
Get = 'GET',
}
const enum RedirectMode {
Follow = 'follow',
}
return new Promise<IncomingMessage>((resolve, reject) => {
const request = net.request({
url,
method: Method.Get,
redirect: RedirectMode.Follow,
})
request.on('response', resolve)
request.on('error', reject)
request.end()
})
}

View File

@@ -0,0 +1,374 @@
import compareVersions from 'compare-versions'
import fs from 'fs'
import path from 'path'
import { MessageToWebApp } from '../../Shared/IpcMessages'
import {
debouncedJSONDiskWriter,
deleteDir,
deleteDirContents,
ensureDirectoryExists,
extractNestedZip,
FileDoesNotExist,
readJSONFile,
} from '../Utils/FileUtils'
import { downloadFile, getJSON } from './Networking'
import { Paths } from '../Types/Paths'
import { AppName } from '../Strings'
import { timeout } from '../Utils/Utils'
import log from 'electron-log'
import { Component, MappingFile, PackageManagerInterface, SyncTask, PackageInfo } from './PackageManagerInterface'
function logMessage(...message: any) {
log.info('PackageManager:', ...message)
}
function logError(...message: any) {
console.error('PackageManager:', ...message)
}
/**
* Safe component mapping manager that queues its disk writes
*/
class MappingFileHandler {
static async create() {
let mapping: MappingFile
try {
const result = await 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))
} else {
logError(error)
}
mapping = {}
}
return new MappingFileHandler(mapping)
}
constructor(private mapping: MappingFile) {}
get = (componendId: string) => {
return this.mapping[componendId]
}
set = (componentId: string, location: string, version: string) => {
this.mapping[componentId] = {
location,
version,
}
this.writeToDisk()
}
remove = (componentId: string) => {
delete this.mapping[componentId]
this.writeToDisk()
}
getInstalledVersionForComponent = async (component: Component): Promise<string> => {
const version = this.get(component.uuid)?.version
if (version) {
return version
}
/**
* If the mapping has no version (pre-3.5 installs) check the component's
* package.json file
*/
const paths = pathsForComponent(component)
const packagePath = path.join(paths.absolutePath, 'package.json')
const response = await 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> {
const syncTasks: SyncTask[] = []
let isRunningTasks = false
const mapping = await MappingFileHandler.create()
return {
syncComponents: async (components: Component[]) => {
logMessage(
'received sync event for:',
components
.map(
({ content, deleted }) =>
// eslint-disable-next-line camelcase
`${content?.name} (${content?.package_info?.version}) ` + `(deleted: ${deleted})`,
)
.join(', '),
)
syncTasks.push({ components })
if (isRunningTasks) {
return
}
isRunningTasks = true
await runTasks(webContents, mapping, syncTasks)
isRunningTasks = false
},
}
}
async function runTasks(webContents: Electron.WebContents, mapping: MappingFileHandler, tasks: SyncTask[]) {
while (tasks.length > 0) {
try {
const oppositeTask = await runTask(webContents, mapping, tasks[0], tasks.slice(1))
if (oppositeTask) {
tasks.splice(tasks.indexOf(oppositeTask), 1)
}
} catch (error) {
logError(error)
} finally {
/** Remove the task from the queue. */
tasks.splice(0, 1)
}
}
}
/**
* @param nextTasks the tasks that follow this one. Useful to see if we
* need to run it at all (for example in the case of a succession of
* install/uninstall)
* @returns If a task opposite to this one was found, returns that tas without
* doing anything. Otherwise undefined.
*/
async function runTask(
webContents: Electron.WebContents,
mapping: MappingFileHandler,
task: SyncTask,
nextTasks: SyncTask[],
): Promise<SyncTask | undefined> {
const maxTries = 3
/** Try to execute the task with up to three tries. */
for (let tries = 1; tries <= maxTries; tries++) {
try {
if (task.components.length === 1 && nextTasks.length > 0) {
/**
* This is a single-component task, AKA an installation or
* deletion
*/
const component = task.components[0]
/**
* See if there is a task opposite to this one, to avoid doing
* unnecessary processing
*/
const oppositeTask = nextTasks.find((otherTask) => {
if (otherTask.components.length > 1) {
/** Only check single-component tasks. */
return false
}
const otherComponent = otherTask.components[0]
return component.uuid === otherComponent.uuid && component.deleted !== otherComponent.deleted
})
if (oppositeTask) {
/** Found an opposite task. return it to the caller and do nothing */
return oppositeTask
}
}
await syncComponents(webContents, mapping, task.components)
/** Everything went well, leave the loop */
return
} catch (error) {
if (tries < maxTries) {
continue
} else {
throw error
}
}
}
}
async function syncComponents(webContents: Electron.WebContents, mapping: MappingFileHandler, components: Component[]) {
/**
* Incoming `components` are what should be installed. For every component,
* check the filesystem and see if that component is installed. If not,
* install it.
*/
await Promise.all(
components.map(async (component) => {
if (component.deleted) {
/** Uninstall */
logMessage(`Uninstalling ${component.content?.name}`)
await uninstallComponent(mapping, component.uuid)
return
}
// eslint-disable-next-line camelcase
if (!component.content?.package_info) {
logMessage('Package info is null, skipping')
return
}
const paths = pathsForComponent(component)
const version = component.content.package_info.version
if (!component.content.local_url) {
/**
* We have a component but it is not mapped to anything on the file system
*/
await installComponent(webContents, mapping, component, component.content.package_info, version)
} else {
try {
/** Will trigger an error if the directory does not exist. */
await fs.promises.lstat(paths.absolutePath)
if (!component.content.autoupdateDisabled) {
await checkForUpdate(webContents, mapping, component)
}
} catch (error: any) {
if (error.code === FileDoesNotExist) {
/** We have a component but no content. Install the component */
await installComponent(webContents, mapping, component, component.content.package_info, version)
} else {
throw error
}
}
}
}),
)
}
async function checkForUpdate(webContents: Electron.WebContents, mapping: MappingFileHandler, component: Component) {
const installedVersion = await mapping.getInstalledVersionForComponent(component)
const latestUrl = component.content?.package_info?.latest_url
if (!latestUrl) {
return
}
const latestJson = await getJSON<PackageInfo>(latestUrl)
if (!latestJson) {
return
}
const latestVersion = latestJson.version
logMessage(
`Checking for update for ${component.content?.name}\n` +
`Latest: ${latestVersion} | Installed: ${installedVersion}`,
)
if (compareVersions(latestVersion, installedVersion) === 1) {
/** Latest version is greater than installed version */
logMessage('Downloading new version', latestVersion)
await installComponent(webContents, mapping, component, latestJson, latestVersion)
}
}
async function installComponent(
webContents: Electron.WebContents,
mapping: MappingFileHandler,
component: Component,
packageInfo: PackageInfo,
version: string,
) {
if (!component.content) {
return
}
const downloadUrl = packageInfo.download_url
if (!downloadUrl) {
return
}
const name = component.content.name
logMessage('Installing ', name, downloadUrl)
const sendInstalledMessage = (component: Component, error?: { message: string; tag: string }) => {
if (error) {
logError(`Error when installing component ${name}: ` + error.message)
} else {
logMessage(`Installed component ${name} (${version})`)
}
webContents.send(MessageToWebApp.InstallComponentComplete, {
component,
error,
})
}
const paths = pathsForComponent(component)
try {
logMessage(`Downloading from ${downloadUrl}`)
/** Download the zip and clear the component's directory in parallel */
await Promise.all([
downloadFile(downloadUrl, paths.downloadPath),
(async () => {
/** Clear the component's directory before extracting the zip. */
await ensureDirectoryExists(paths.absolutePath)
await deleteDirContents(paths.absolutePath)
})(),
])
logMessage('Extracting', paths.downloadPath, 'to', paths.absolutePath)
await extractNestedZip(paths.downloadPath, paths.absolutePath)
let main = 'index.html'
try {
/** Try to read 'sn.main' field from 'package.json' file */
const packageJsonPath = path.join(paths.absolutePath, 'package.json')
const packageJson = await readJSONFile<{
sn?: { main?: string }
version?: string
}>(packageJsonPath)
if (packageJson?.sn?.main) {
main = packageJson.sn.main
}
} catch (error) {
logError(error)
}
component.content.local_url = 'sn://' + paths.relativePath + '/' + main
component.content.package_info.download_url = packageInfo.download_url
component.content.package_info.latest_url = packageInfo.latest_url
component.content.package_info.url = packageInfo.url
component.content.package_info.version = packageInfo.version
mapping.set(component.uuid, paths.relativePath, version)
sendInstalledMessage(component)
} catch (error: any) {
logMessage(`Error while installing ${component.content.name}`, error.message)
/**
* Waiting five seconds prevents clients from spamming install requests
* of faulty components
*/
const fiveSeconds = 5000
await timeout(fiveSeconds)
sendInstalledMessage(component, {
message: error.message,
tag: 'error-downloading',
})
}
}
function pathsForComponent(component: Pick<Component, 'content'>) {
const relativePath = path.join(Paths.extensionsDirRelative, component.content!.package_info.identifier)
return {
relativePath,
absolutePath: path.join(Paths.userDataDir, relativePath),
downloadPath: path.join(Paths.tempDir, AppName, 'downloads', component.content!.name + '.zip'),
}
}
async function uninstallComponent(mapping: MappingFileHandler, uuid: string) {
const componentMapping = mapping.get(uuid)
if (!componentMapping || !componentMapping.location) {
/** No mapping for component */
return
}
await deleteDir(path.join(Paths.userDataDir, componentMapping.location))
mapping.remove(uuid)
}

View File

@@ -0,0 +1,35 @@
export interface PackageManagerInterface {
syncComponents(components: Component[]): Promise<void>
}
export interface Component {
uuid: string
deleted: boolean
content?: {
name?: string
autoupdateDisabled: boolean
local_url?: string
package_info: PackageInfo
}
}
export type PackageInfo = {
identifier: string
version: string
download_url: string
latest_url: string
url: string
}
export interface SyncTask {
components: Component[]
}
export interface MappingFile {
[key: string]: Readonly<ComponentMapping> | undefined
}
export interface ComponentMapping {
location: string
version?: string
}

View File

@@ -0,0 +1,3 @@
export interface RemoteDataInterface {
destroySensitiveDirectories(): void
}

View File

@@ -0,0 +1,204 @@
import { CrossProcessBridge } from '../../Renderer/CrossProcessBridge'
import { Store, StoreKeys } from '../Store'
const path = require('path')
const rendererPath = path.join('file://', __dirname, '/renderer.js')
import { app, BrowserWindow } from 'electron'
import { KeychainInterface } from '../Keychain/KeychainInterface'
import { BackupsManagerInterface } from '../Backups/BackupsManagerInterface'
import { PackageManagerInterface, Component } from '../Packages/PackageManagerInterface'
import { SearchManagerInterface } from '../Search/SearchManagerInterface'
import { RemoteDataInterface } from './DataInterface'
import { MenuManagerInterface } from '../Menus/MenuManagerInterface'
import { FileBackupsDevice, FileBackupsMapping } from '@web/Application/Device/DesktopSnjsExports'
/**
* Read https://github.com/electron/remote to understand how electron/remote works.
* RemoteBridge is imported from the Preload process but is declared and created on the main process.
*/
export class RemoteBridge implements CrossProcessBridge {
constructor(
private window: BrowserWindow,
private keychain: KeychainInterface,
private backups: BackupsManagerInterface,
private packages: PackageManagerInterface,
private search: SearchManagerInterface,
private data: RemoteDataInterface,
private menus: MenuManagerInterface,
private fileBackups: FileBackupsDevice,
) {}
get exposableValue(): CrossProcessBridge {
return {
extServerHost: this.extServerHost,
useNativeKeychain: this.useNativeKeychain,
isMacOS: this.isMacOS,
appVersion: this.appVersion,
useSystemMenuBar: this.useSystemMenuBar,
rendererPath: this.rendererPath,
closeWindow: this.closeWindow.bind(this),
minimizeWindow: this.minimizeWindow.bind(this),
maximizeWindow: this.maximizeWindow.bind(this),
unmaximizeWindow: this.unmaximizeWindow.bind(this),
isWindowMaximized: this.isWindowMaximized.bind(this),
getKeychainValue: this.getKeychainValue.bind(this),
setKeychainValue: this.setKeychainValue.bind(this),
clearKeychainValue: this.clearKeychainValue.bind(this),
localBackupsCount: this.localBackupsCount.bind(this),
viewlocalBackups: this.viewlocalBackups.bind(this),
deleteLocalBackups: this.deleteLocalBackups.bind(this),
displayAppMenu: this.displayAppMenu.bind(this),
saveDataBackup: this.saveDataBackup.bind(this),
syncComponents: this.syncComponents.bind(this),
onMajorDataChange: this.onMajorDataChange.bind(this),
onSearch: this.onSearch.bind(this),
onInitialDataLoad: this.onInitialDataLoad.bind(this),
destroyAllData: this.destroyAllData.bind(this),
getFilesBackupsMappingFile: this.getFilesBackupsMappingFile.bind(this),
saveFilesBackupsFile: this.saveFilesBackupsFile.bind(this),
isFilesBackupsEnabled: this.isFilesBackupsEnabled.bind(this),
enableFilesBackups: this.enableFilesBackups.bind(this),
disableFilesBackups: this.disableFilesBackups.bind(this),
changeFilesBackupsLocation: this.changeFilesBackupsLocation.bind(this),
getFilesBackupsLocation: this.getFilesBackupsLocation.bind(this),
openFilesBackupsLocation: this.openFilesBackupsLocation.bind(this),
}
}
get extServerHost() {
return Store.get(StoreKeys.ExtServerHost)
}
get useNativeKeychain() {
return Store.get(StoreKeys.UseNativeKeychain) ?? true
}
get rendererPath() {
return rendererPath
}
get isMacOS() {
return process.platform === 'darwin'
}
get appVersion() {
return app.getVersion()
}
get useSystemMenuBar() {
return Store.get(StoreKeys.UseSystemMenuBar)
}
closeWindow() {
this.window.close()
}
minimizeWindow() {
this.window.minimize()
}
maximizeWindow() {
this.window.maximize()
}
unmaximizeWindow() {
this.window.unmaximize()
}
isWindowMaximized() {
return this.window.isMaximized()
}
async getKeychainValue() {
return this.keychain.getKeychainValue()
}
async setKeychainValue(value: unknown) {
return this.keychain.setKeychainValue(value)
}
async clearKeychainValue() {
return this.keychain.clearKeychainValue()
}
async localBackupsCount() {
return this.backups.backupsCount()
}
viewlocalBackups() {
this.backups.viewBackups()
}
async deleteLocalBackups() {
return this.backups.deleteBackups()
}
syncComponents(components: Component[]) {
void this.packages.syncComponents(components)
}
onMajorDataChange() {
this.backups.performBackup()
}
onSearch(text: string) {
this.search.findInPage(text)
}
onInitialDataLoad() {
this.backups.beginBackups()
}
destroyAllData() {
this.data.destroySensitiveDirectories()
}
saveDataBackup(data: unknown) {
this.backups.saveBackupData(data)
}
displayAppMenu() {
this.menus.popupMenu()
}
getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
return this.fileBackups.getFilesBackupsMappingFile()
}
saveFilesBackupsFile(
uuid: string,
metaFile: string,
downloadRequest: {
chunkSizes: number[]
valetToken: string
url: string
},
): Promise<'success' | 'failed'> {
return this.fileBackups.saveFilesBackupsFile(uuid, metaFile, downloadRequest)
}
public isFilesBackupsEnabled(): Promise<boolean> {
return this.fileBackups.isFilesBackupsEnabled()
}
public enableFilesBackups(): Promise<void> {
return this.fileBackups.enableFilesBackups()
}
public disableFilesBackups(): Promise<void> {
return this.fileBackups.disableFilesBackups()
}
public changeFilesBackupsLocation(): Promise<string | undefined> {
return this.fileBackups.changeFilesBackupsLocation()
}
public getFilesBackupsLocation(): Promise<string> {
return this.fileBackups.getFilesBackupsLocation()
}
public openFilesBackupsLocation(): Promise<void> {
return this.fileBackups.openFilesBackupsLocation()
}
}

View File

@@ -0,0 +1,15 @@
import { WebContents } from 'electron'
import { SearchManagerInterface } from './SearchManagerInterface'
export function initializeSearchManager(webContents: WebContents): SearchManagerInterface {
return {
findInPage(text: string) {
webContents.stopFindInPage('clearSelection')
if (text && text.length > 0) {
// This option arrangement is required to avoid an issue where clicking on a
// different note causes scroll to jump.
webContents.findInPage(text)
}
},
}
}

View File

@@ -0,0 +1,3 @@
export interface SearchManagerInterface {
findInPage(text: string): void
}

View File

@@ -0,0 +1,214 @@
/* eslint-disable no-inline-comments */
import { isMac } from './Types/Platforms'
import { Store, StoreKeys } from './Store'
import { isDev } from './Utils/Utils'
export enum Language {
AF = 'af',
ID = 'id',
CA = 'ca',
CS = 'cs',
CY = 'cy',
DA = 'da',
DE = 'de',
SH = 'sh',
ET = 'et',
EN_AU = 'en-AU',
EN_CA = 'en-CA',
EN_GB = 'en-GB',
EN_US = 'en-US',
ES = 'es',
ES_419 = 'es-419',
ES_ES = 'es-ES',
ES_US = 'es-US',
ES_MX = 'es-MX',
ES_AR = 'es-AR',
FO = 'fo',
FR = 'fr',
HR = 'hr',
IT = 'it',
PL = 'pl',
LV = 'lv',
LT = 'lt',
HU = 'hu',
NL = 'nl',
NB = 'nb',
PT_BR = 'pt-BR',
PT_PT = 'pt-PT',
RO = 'ro',
SQ = 'sq',
SK = 'sk',
SL = 'sl',
SV = 'sv',
VI = 'vi',
TR = 'tr',
EL = 'el',
BG = 'bg',
RU = 'ru',
SR = 'sr',
TG = 'tg',
UK = 'uk',
HY = 'hy',
HE = 'he',
FA = 'fa',
HI = 'hi',
TA = 'ta',
KO = 'ko',
}
function isLanguage(language: any): language is Language {
return Object.values(Language).includes(language)
}
function log(...message: any) {
console.log('spellcheckerMaager:', ...message)
}
export interface SpellcheckerManager {
languages(): Array<{
code: string
name: string
enabled: boolean
}>
addLanguage(code: string): void
removeLanguage(code: string): void
}
export function createSpellcheckerManager(
store: Store,
webContents: Electron.WebContents,
userLocale: string,
): SpellcheckerManager | undefined {
/**
* On MacOS the system spellchecker is used and every related Electron method
* is a no-op. Return early to prevent unnecessary code execution/allocations
*/
if (isMac()) {
return
}
const session = webContents.session
/**
* Mapping of language codes predominantly based on
* https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
*/
const LanguageCodes: Readonly<Record<Language, string>> = {
af: 'Afrikaans' /** Afrikaans */,
id: 'Bahasa Indonesia' /** Indonesian */,
ca: 'Català, Valencià' /** Catalan, Valencian */,
cs: 'Čeština, Český Jazyk' /** Czech */,
cy: 'Cymraeg' /** Welsh */,
da: 'Dansk' /** Danish */,
de: 'Deutsch' /** German */,
sh: 'Deutsch, Schaffhausen' /** German, Canton of Schaffhausen */,
et: 'Eesti, Eesti Keel' /** Estonian */,
'en-AU': 'English, Australia',
'en-CA': 'English, Canada',
'en-GB': 'English, Great Britain',
'en-US': 'English, United States',
es: 'Español' /** Spanish, Castilian */,
'es-419': 'Español, America Latina' /** Spanish, Latin American */,
'es-ES': 'Español, España' /** Spanish, Spain */,
'es-US': 'Español, Estados Unidos de América' /** Spanish, United States */,
'es-MX': 'Español, Estados Unidos Mexicanos' /** Spanish, Mexico */,
'es-AR': 'Español, República Argentina' /** Spanish, Argentine Republic */,
fo: 'Føroyskt' /** Faroese */,
fr: 'Français' /** French */,
hr: 'Hrvatski Jezik' /** Croatian */,
it: 'Italiano' /** Italian */,
pl: 'Język Polski, Polszczyzna' /** Polish */,
lv: 'Latviešu Valoda' /** Latvian */,
lt: 'Lietuvių Kalba' /** Lithuanian */,
hu: 'Magyar' /** Hungarian */,
nl: 'Nederlands, Vlaams' /** Dutch, Flemish */,
nb: 'Norsk Bokmål' /** Norwegian Bokmål */,
'pt-BR': 'Português, Brasil' /** Portuguese, Brazil */,
'pt-PT': 'Português, República Portuguesa' /** Portuguese, Portugal */,
ro: 'Română' /** Romanian, Moldavian, Moldovan */,
sq: 'Shqip' /** Albanian */,
sk: 'Slovenčina, Slovenský Jazyk' /** Slovak */,
sl: 'Slovenski Jezik, Slovenščina' /** Slovenian */,
sv: 'Svenska' /** Swedish */,
vi: 'Tiếng Việt' /** Vietnamese */,
tr: 'Türkçe' /** Turkish */,
el: 'ελληνικά' /** Greek */,
bg: 'български език' /** Bulgarian */,
ru: 'Русский' /** Russian */,
sr: 'српски језик' /** Serbian */,
tg: 'тоҷикӣ, toçikī, تاجیکی‎' /** Tajik */,
uk: 'Українська' /** Ukrainian */,
hy: 'Հայերեն' /** Armenian */,
he: 'עברית' /** Hebrew */,
fa: 'فارسی' /** Persian */,
hi: 'हिन्दी, हिंदी' /** Hindi */,
ta: 'தமிழ்' /** Tamil */,
ko: '한국어' /** Korean */,
}
const availableSpellCheckerLanguages = Object.values(Language).filter((language) =>
session.availableSpellCheckerLanguages.includes(language),
)
if (isDev() && availableSpellCheckerLanguages.length !== session.availableSpellCheckerLanguages.length) {
/** This means that not every available language has been accounted for. */
const firstOutlier = session.availableSpellCheckerLanguages.find(
(language, index) => availableSpellCheckerLanguages[index] !== language,
)
throw new Error(`Found unsupported language code: ${firstOutlier}`)
}
setSpellcheckerLanguages()
function setSpellcheckerLanguages() {
const { session } = webContents
let selectedCodes = store.get(StoreKeys.SelectedSpellCheckerLanguageCodes)
if (selectedCodes === null) {
/** First-time setup. Set a default language */
selectedCodes = determineDefaultSpellcheckerLanguageCodes(session.availableSpellCheckerLanguages, userLocale)
store.set(StoreKeys.SelectedSpellCheckerLanguageCodes, selectedCodes)
}
session.setSpellCheckerLanguages([...selectedCodes])
}
function determineDefaultSpellcheckerLanguageCodes(
availableSpellCheckerLanguages: string[],
userLocale: string,
): Set<Language> {
const localeIsSupported = availableSpellCheckerLanguages.includes(userLocale)
if (localeIsSupported && isLanguage(userLocale)) {
return new Set([userLocale])
} else {
log(`Spellchecker doesn't support locale '${userLocale}'.`)
return new Set()
}
}
function selectedLanguageCodes(): Set<Language> {
return store.get(StoreKeys.SelectedSpellCheckerLanguageCodes) || new Set()
}
return {
languages() {
const codes = selectedLanguageCodes()
return availableSpellCheckerLanguages.map((code) => ({
code,
name: LanguageCodes[code],
enabled: codes.has(code),
}))
},
addLanguage(code: Language) {
const selectedCodes = selectedLanguageCodes()
selectedCodes.add(code)
store.set(StoreKeys.SelectedSpellCheckerLanguageCodes, selectedCodes)
session.setSpellCheckerLanguages(Array.from(selectedCodes))
},
removeLanguage(code: Language) {
const selectedCodes = selectedLanguageCodes()
selectedCodes.delete(code)
store.set(StoreKeys.SelectedSpellCheckerLanguageCodes, selectedCodes)
session.setSpellCheckerLanguages(Array.from(selectedCodes))
},
}
}

View File

@@ -0,0 +1,185 @@
import fs from 'fs'
import path from 'path'
import { MessageType } from '../../../test/TestIpcMessage'
import { Language } from './SpellcheckerManager'
import { ensureIsBoolean, isTesting, isDev, isBoolean } from './Utils/Utils'
import { FileDoesNotExist } from './Utils/FileUtils'
import { BackupsDirectoryName } from './Backups/BackupsManager'
import { handleTestMessage } from './Utils/Testing'
const app = process.type === 'browser' ? require('electron').app : require('@electron/remote').app
function logError(...message: any) {
console.error('store:', ...message)
}
export enum StoreKeys {
ExtServerHost = 'extServerHost',
UseSystemMenuBar = 'useSystemMenuBar',
MenuBarVisible = 'isMenuBarVisible',
BackupsLocation = 'backupsLocation',
BackupsDisabled = 'backupsDisabled',
MinimizeToTray = 'minimizeToTray',
EnableAutoUpdate = 'enableAutoUpdates',
ZoomFactor = 'zoomFactor',
SelectedSpellCheckerLanguageCodes = 'selectedSpellCheckerLanguageCodes',
UseNativeKeychain = 'useNativeKeychain',
FileBackupsEnabled = 'fileBackupsEnabled',
FileBackupsLocation = 'fileBackupsLocation',
}
interface StoreData {
[StoreKeys.ExtServerHost]: string
[StoreKeys.UseSystemMenuBar]: boolean
[StoreKeys.MenuBarVisible]: boolean
[StoreKeys.BackupsLocation]: string
[StoreKeys.BackupsDisabled]: boolean
[StoreKeys.MinimizeToTray]: boolean
[StoreKeys.EnableAutoUpdate]: boolean
[StoreKeys.UseNativeKeychain]: boolean | null
[StoreKeys.ZoomFactor]: number
[StoreKeys.SelectedSpellCheckerLanguageCodes]: Set<Language> | null
[StoreKeys.FileBackupsEnabled]: boolean
[StoreKeys.FileBackupsLocation]: string
}
function createSanitizedStoreData(data: any = {}): StoreData {
return {
[StoreKeys.MenuBarVisible]: ensureIsBoolean(data[StoreKeys.MenuBarVisible], true),
[StoreKeys.UseSystemMenuBar]: ensureIsBoolean(data[StoreKeys.UseSystemMenuBar], false),
[StoreKeys.BackupsDisabled]: ensureIsBoolean(data[StoreKeys.BackupsDisabled], false),
[StoreKeys.MinimizeToTray]: ensureIsBoolean(data[StoreKeys.MinimizeToTray], false),
[StoreKeys.EnableAutoUpdate]: ensureIsBoolean(data[StoreKeys.EnableAutoUpdate], true),
[StoreKeys.UseNativeKeychain]: isBoolean(data[StoreKeys.UseNativeKeychain])
? data[StoreKeys.UseNativeKeychain]
: null,
[StoreKeys.ExtServerHost]: data[StoreKeys.ExtServerHost],
[StoreKeys.BackupsLocation]: sanitizeBackupsLocation(data[StoreKeys.BackupsLocation]),
[StoreKeys.ZoomFactor]: sanitizeZoomFactor(data[StoreKeys.ZoomFactor]),
[StoreKeys.SelectedSpellCheckerLanguageCodes]: sanitizeSpellCheckerLanguageCodes(
data[StoreKeys.SelectedSpellCheckerLanguageCodes],
),
[StoreKeys.FileBackupsEnabled]: ensureIsBoolean(data[StoreKeys.FileBackupsEnabled], false),
[StoreKeys.FileBackupsLocation]: data[StoreKeys.FileBackupsLocation],
}
}
function sanitizeZoomFactor(factor?: any): number {
if (typeof factor === 'number' && factor > 0) {
return factor
} else {
return 1
}
}
function sanitizeBackupsLocation(location?: unknown): string {
const defaultPath = path.join(
isTesting() ? app.getPath('userData') : isDev() ? app.getPath('documents') : app.getPath('home'),
BackupsDirectoryName,
)
if (typeof location !== 'string') {
return defaultPath
}
try {
const stat = fs.lstatSync(location)
if (stat.isDirectory()) {
return location
}
/** Path points to something other than a directory */
return defaultPath
} catch (e) {
/** Path does not point to a valid directory */
logError(e)
return defaultPath
}
}
function sanitizeSpellCheckerLanguageCodes(languages?: unknown): Set<Language> | null {
if (!languages) {
return null
}
if (!Array.isArray(languages)) {
return null
}
const set = new Set<Language>()
const validLanguages = Object.values(Language)
for (const language of languages) {
if (validLanguages.includes(language)) {
set.add(language)
}
}
return set
}
export function serializeStoreData(data: StoreData): string {
return JSON.stringify(data, (_key, value) => {
if (value instanceof Set) {
return Array.from(value)
}
return value
})
}
function parseDataFile(filePath: string) {
try {
const fileData = fs.readFileSync(filePath)
const userData = JSON.parse(fileData.toString())
return createSanitizedStoreData(userData)
} catch (error: any) {
console.log('Error reading store file', error)
if (error.code !== FileDoesNotExist) {
logError(error)
}
return createSanitizedStoreData({})
}
}
export class Store {
static instance: Store
readonly path: string
readonly data: StoreData
static getInstance(): Store {
if (!this.instance) {
/**
* Renderer process has to get `app` module via `remote`, whereas the main process
* can get it directly app.getPath('userData') will return a string of the user's
* app data directory path.
* TODO(baptiste): stop using Store in the renderer process.
*/
const userDataPath = app.getPath('userData')
this.instance = new Store(userDataPath)
}
return this.instance
}
static get<T extends keyof StoreData>(key: T): StoreData[T] {
return this.getInstance().get(key)
}
constructor(userDataPath: string) {
this.path = path.join(userDataPath, 'user-preferences.json')
this.data = parseDataFile(this.path)
if (isTesting()) {
handleTestMessage(MessageType.StoreSettingsLocation, () => this.path)
handleTestMessage(MessageType.StoreSet, (key, value) => {
this.set(key, value)
})
}
}
get<T extends keyof StoreData>(key: T): StoreData[T] {
return this.data[key]
}
set<T extends keyof StoreData>(key: T, val: StoreData[T]): void {
this.data[key] = val
fs.writeFileSync(this.path, serializeStoreData(this.data))
}
}

View File

@@ -0,0 +1,160 @@
import { Strings } from './types'
export function createEnglishStrings(): Strings {
return {
appMenu: {
edit: 'Edit',
view: 'View',
hideMenuBar: 'Hide Menu Bar',
useThemedMenuBar: 'Use Themed Menu Bar',
minimizeToTrayOnClose: 'Minimize To Tray On Close',
backups: 'Backups',
enableAutomaticUpdates: 'Enable Automatic Updates',
automaticUpdatesDisabled: 'Automatic Updates Disabled',
disableAutomaticBackups: 'Disable Automatic Backups',
enableAutomaticBackups: 'Enable Automatic Backups',
changeBackupsLocation: 'Change Backups Location',
openBackupsLocation: 'Open Backups Location',
emailSupport: 'Email Support',
website: 'Website',
gitHub: 'GitHub',
slack: 'Slack',
twitter: 'Twitter',
toggleErrorConsole: 'Toggle Error Console',
openDataDirectory: 'Open Data Directory',
clearCacheAndReload: 'Clear Cache and Reload',
speech: 'Speech',
close: 'Close',
minimize: 'Minimize',
zoom: 'Zoom',
bringAllToFront: 'Bring All to Front',
checkForUpdate: 'Check for Update',
checkingForUpdate: 'Checking for update…',
updateAvailable: '(1) Update Available',
updates: 'Updates',
releaseNotes: 'Release Notes',
openDownloadLocation: 'Open Download Location',
downloadingUpdate: 'Downloading Update…',
manuallyDownloadUpdate: 'Manually Download Update',
spellcheckerLanguages: 'Spellchecker Languages',
installPendingUpdate(versionNumber: string) {
return `Install Pending Update (${versionNumber})`
},
lastUpdateCheck(date: Date) {
return `Last checked ${date.toLocaleString()}`
},
version(number: string) {
return `Version: ${number}`
},
yourVersion(number: string) {
return `Your Version: ${number}`
},
latestVersion(number: string) {
return `Latest Version: ${number}`
},
viewReleaseNotes(versionNumber: string) {
return `View ${versionNumber} Release Notes`
},
preferencesChanged: {
title: 'Preference Changed',
message:
'Your menu bar preference has been saved. Please restart the ' + 'application for the change to take effect.',
},
security: {
security: 'Security',
useKeyringtoStorePassword: 'Use password storage to store password',
enabledKeyringAccessMessage:
"Standard Notes will try to use your system's password storage " +
'facility to store your password the next time you start it.',
enabledKeyringQuitNow: 'Quit Now',
enabledKeyringPostpone: 'Postpone',
},
},
contextMenu: {
learnSpelling: 'Learn Spelling',
noSuggestions: 'No Suggestions',
},
tray: {
show: 'Show',
hide: 'Hide',
quit: 'Quit',
},
extensions: {
missingExtension:
'The extension was not found on your system, possibly because it is ' +
"still downloading. If the extension doesn't load, " +
'try uninstalling then reinstalling the extension.',
unableToLoadExtension:
'Unable to load extension. Please restart the application and ' +
'try again. If the issue persists, try uninstalling then ' +
'reinstalling the extension.',
},
updates: {
automaticUpdatesEnabled: {
title: 'Automatic Updates Enabled.',
message:
'Automatic updates have been enabled. Please note that ' +
'this functionality is currently in beta, and that you are advised ' +
'to periodically check in and ensure you are running the ' +
'latest version.',
},
finishedChecking: {
title: 'Finished checking for updates.',
error(description: string) {
return (
'An issue occurred while checking for updates. ' +
'Please try again.\nIf this issue persists please contact ' +
`support with the following information: ${description}`
)
},
updateAvailable(newVersion: string) {
return (
`A new update is available (version ${newVersion}). ` +
'You can wait for the app to update itself, or manually ' +
'download and install this update.'
)
},
noUpdateAvailable(currentVersion: string) {
return `Your version (${currentVersion}) is the latest available version.`
},
},
updateReady: {
title: 'Update Ready',
message(version: string) {
return `A new update (version ${version}) is ready to install.`
},
quitAndInstall: 'Quit and Install',
installLater: 'Install Later',
noRecentBackupMessage:
'An update is ready to install, but your backups folder does not ' +
'appear to contain a recent enough backup. Please download a ' +
'backup manually before proceeding with the installation.',
noRecentBackupDetail(lastBackupDate: number | null) {
const downloadInstructions =
'You can download a backup from the Account menu ' + 'in the bottom-left corner of the app.'
const lastAutomaticBackup =
lastBackupDate === null
? 'Your backups folder is empty.'
: `Your latest automatic backup is from ${new Date(lastBackupDate).toLocaleString()}.`
return `${downloadInstructions}\n${lastAutomaticBackup}`
},
noRecentBackupChecbox: 'I have downloaded a backup, proceed with installation',
},
errorDownloading: {
title: 'Error Downloading',
message: 'An error occurred while trying to download your ' + 'update file. Please try again.',
},
unknownVersionName: 'Unknown',
},
backups: {
errorChangingDirectory(error: any): string {
return (
'An error occurred while changing your backups directory. ' +
'If this issue persists, please contact support with the following ' +
'information: \n' +
JSON.stringify(error)
)
},
},
}
}

View File

@@ -0,0 +1,42 @@
import { Strings } from './types'
import { createEnglishStrings } from './english'
import { isDev } from '../Utils/Utils'
export function createFrenchStrings(): Strings {
const fallback = createEnglishStrings()
if (!isDev()) {
/**
* Le Français est une langue expérimentale.
* Don't show it in production yet.
*/
return fallback
}
return {
appMenu: {
...fallback.appMenu,
edit: 'Édition',
view: 'Affichage',
},
contextMenu: {
learnSpelling: "Mémoriser l'orthographe",
noSuggestions: 'Aucune suggestion',
},
tray: {
show: 'Afficher',
hide: 'Masquer',
quit: 'Quitter',
},
extensions: fallback.extensions,
updates: fallback.updates,
backups: {
errorChangingDirectory(error: any): string {
return (
"Une erreur s'est produite lors du déplacement du dossier de " +
'sauvegardes. Si le problème est récurrent, contactez le support ' +
'technique (en anglais) avec les informations suivantes:\n' +
JSON.stringify(error)
)
},
},
}
}

View File

@@ -0,0 +1,70 @@
import { createEnglishStrings } from './english'
import { createFrenchStrings } from './french'
import { Strings } from './types'
import { isDev } from '../Utils/Utils'
let strings: Strings
/**
* MUST be called (once) before using any other export in this file.
* @param locale The user's locale
* @see https://www.electronjs.org/docs/api/locales
*/
export function initializeStrings(locale: string): void {
if (isDev()) {
if (strings) {
throw new Error('`strings` has already been initialized')
}
}
if (strings) {
return
}
strings = stringsForLocale(locale)
}
export function str(): Strings {
if (isDev()) {
if (!strings) {
throw new Error('tried to access strings before they were initialized.')
}
}
return strings
}
export function appMenu() {
return str().appMenu
}
export function contextMenu() {
return str().contextMenu
}
export function tray() {
return str().tray
}
export function extensions() {
return str().extensions
}
export function updates() {
return str().updates
}
export function backups() {
return str().backups
}
function stringsForLocale(locale: string): Strings {
if (locale === 'en' || locale.startsWith('en-')) {
return createEnglishStrings()
} else if (locale === 'fr' || locale.startsWith('fr-')) {
return createFrenchStrings()
}
return createEnglishStrings()
}
export const AppName = 'Standard Notes'

View File

@@ -0,0 +1,109 @@
export interface Strings {
appMenu: AppMenuStrings
contextMenu: ContextMenuStrings
tray: TrayStrings
extensions: ExtensionsStrings
updates: UpdateStrings
backups: BackupsStrings
}
interface AppMenuStrings {
edit: string
view: string
hideMenuBar: string
useThemedMenuBar: string
minimizeToTrayOnClose: string
backups: string
enableAutomaticUpdates: string
automaticUpdatesDisabled: string
disableAutomaticBackups: string
enableAutomaticBackups: string
changeBackupsLocation: string
openBackupsLocation: string
emailSupport: string
website: string
gitHub: string
slack: string
twitter: string
toggleErrorConsole: string
openDataDirectory: string
clearCacheAndReload: string
speech: string
close: string
minimize: string
zoom: string
bringAllToFront: string
checkForUpdate: string
checkingForUpdate: string
updateAvailable: string
updates: string
releaseNotes: string
openDownloadLocation: string
downloadingUpdate: string
manuallyDownloadUpdate: string
spellcheckerLanguages: string
installPendingUpdate(versionNumber: string): string
lastUpdateCheck(date: Date): string
version(number: string): string
yourVersion(number: string): string
latestVersion(number: string): string
viewReleaseNotes(versionNumber: string): string
preferencesChanged: {
title: string
message: string
}
security: {
security: string
useKeyringtoStorePassword: string
enabledKeyringAccessMessage: string
enabledKeyringQuitNow: string
enabledKeyringPostpone: string
}
}
interface ContextMenuStrings {
learnSpelling: string
noSuggestions: string
}
interface TrayStrings {
show: string
hide: string
quit: string
}
interface ExtensionsStrings {
unableToLoadExtension: string
missingExtension: string
}
interface UpdateStrings {
automaticUpdatesEnabled: {
title: string
message: string
}
finishedChecking: {
title: string
error(description: string): string
updateAvailable(newVersion: string): string
noUpdateAvailable(currentVersion: string): string
}
updateReady: {
title: string
message(version: string): string
quitAndInstall: string
installLater: string
noRecentBackupMessage: string
noRecentBackupDetail(lastBackupDate: number | null): string
noRecentBackupChecbox: string
}
errorDownloading: {
title: string
message: string
}
unknownVersionName: string
}
interface BackupsStrings {
errorChangingDirectory(error: any): string
}

View File

@@ -0,0 +1,109 @@
import { Menu, Tray } from 'electron'
import path from 'path'
import { isLinux, isWindows } from './Types/Platforms'
import { Store, StoreKeys } from './Store'
import { AppName, tray as str } from './Strings'
import { isDev } from './Utils/Utils'
const icon = path.join(__dirname, '/icon/Icon-256x256.png')
export interface TrayManager {
shouldMinimizeToTray(): boolean
createTrayIcon(): void
destroyTrayIcon(): void
}
export function createTrayManager(window: Electron.BrowserWindow, store: Store): TrayManager {
let tray: Tray | undefined
let updateContextMenu: (() => void) | undefined
function showWindow() {
window.show()
if (isLinux()) {
/* On some versions of GNOME the window may not be on top when
restored. */
window.setAlwaysOnTop(true)
window.focus()
window.setAlwaysOnTop(false)
}
}
return {
shouldMinimizeToTray() {
return store.get(StoreKeys.MinimizeToTray)
},
createTrayIcon() {
tray = new Tray(icon)
tray.setToolTip(AppName)
if (isWindows()) {
/* On Windows, right-clicking invokes the menu, as opposed to
left-clicking for the other platforms. So we map left-clicking
to the conventional action of showing the app. */
tray.on('click', showWindow)
}
const SHOW_WINDOW_ID = 'SHOW_WINDOW'
const HIDE_WINDOW_ID = 'HIDE_WINDOW'
const trayContextMenu = Menu.buildFromTemplate([
{
id: SHOW_WINDOW_ID,
label: str().show,
click: showWindow,
},
{
id: HIDE_WINDOW_ID,
label: str().hide,
click() {
window.hide()
},
},
{
type: 'separator',
},
{
role: 'quit',
label: str().quit,
},
])
updateContextMenu = function updateContextMenu() {
if (window.isVisible()) {
trayContextMenu.getMenuItemById(SHOW_WINDOW_ID)!.visible = false
trayContextMenu.getMenuItemById(HIDE_WINDOW_ID)!.visible = true
} else {
trayContextMenu.getMenuItemById(SHOW_WINDOW_ID)!.visible = true
trayContextMenu.getMenuItemById(HIDE_WINDOW_ID)!.visible = false
}
tray!.setContextMenu(trayContextMenu)
}
updateContextMenu()
window.on('hide', updateContextMenu)
window.on('focus', updateContextMenu)
window.on('blur', updateContextMenu)
},
destroyTrayIcon() {
if (isDev()) {
/** Check our state */
if (!updateContextMenu) {
throw new Error('updateContextMenu === undefined')
}
if (!tray) {
throw new Error('tray === undefined')
}
}
window.off('hide', updateContextMenu!)
window.off('focus', updateContextMenu!)
window.off('blur', updateContextMenu!)
tray!.destroy()
tray = undefined
updateContextMenu = undefined
},
}
}

View File

@@ -0,0 +1,6 @@
/** Build-time constants */
declare const IS_SNAP: boolean
export const isSnap = IS_SNAP
export const autoUpdatingAvailable = !isSnap
export const keychainAccessIsUserConfigurable = isSnap

View File

@@ -0,0 +1,69 @@
import path from 'path'
import index from '../../../index.html'
import grantLinuxPasswordsAccess from '../../../grantLinuxPasswordsAccess.html'
import decryptScript from 'decrypt/dist/decrypt.html'
import { app } from 'electron'
function url(fileName: string): string {
if ('APP_RELATIVE_PATH' in process.env) {
return path.join('file://', __dirname, process.env.APP_RELATIVE_PATH as string, fileName)
}
return path.join('file://', __dirname, fileName)
}
function filePath(fileName: string): string {
if ('APP_RELATIVE_PATH' in process.env) {
return path.join(__dirname, process.env.APP_RELATIVE_PATH as string, fileName)
}
return path.join(__dirname, fileName)
}
export const Urls = {
get indexHtml(): string {
return url(index)
},
get grantLinuxPasswordsAccessHtml(): string {
return url(grantLinuxPasswordsAccess)
},
}
/**
* App paths can be modified at runtime, most frequently at startup, so don't
* store the results of these getters in long-lived constants (like static class
* fields).
*/
export const Paths = {
get userDataDir(): string {
return app.getPath('userData')
},
get documentsDir(): string {
return app.getPath('documents')
},
get tempDir(): string {
return app.getPath('temp')
},
get extensionsDirRelative(): string {
return 'Extensions'
},
get extensionsDir(): string {
return path.join(Paths.userDataDir, 'Extensions')
},
get extensionsMappingJson(): string {
return path.join(Paths.extensionsDir, 'mapping.json')
},
get windowPositionJson(): string {
return path.join(Paths.userDataDir, 'window-position.json')
},
get decryptScript(): string {
return filePath(decryptScript)
},
get preloadJs(): string {
return path.join(__dirname, 'javascripts/renderer/preload.js')
},
get components(): string {
return `${app.getAppPath()}/dist/web/components`
},
get grantLinuxPasswordsAccessJs(): string {
return path.join(__dirname, 'javascripts/renderer/grantLinuxPasswordsAccess.js')
},
}

View File

@@ -0,0 +1,31 @@
/**
* TODO(baptiste): precompute these booleans at compile-time
* (requires one webpack build per platform)
*/
export function isWindows(): boolean {
return process.platform === 'win32'
}
export function isMac(): boolean {
return process.platform === 'darwin'
}
export function isLinux(): boolean {
return process.platform === 'linux'
}
export type InstallerKey = 'mac' | 'windows' | 'appimage_64' | 'appimage_32'
export function getInstallerKey(): InstallerKey {
if (isWindows()) {
return 'windows'
} else if (isMac()) {
return 'mac'
} else if (isLinux()) {
if (process.arch === 'x32') {
return 'appimage_32'
} else {
return 'appimage_64'
}
} else {
throw new Error(`Unknown platform: ${process.platform}`)
}
}

View File

@@ -0,0 +1,249 @@
import compareVersions from 'compare-versions'
import { BrowserWindow, dialog, shell } from 'electron'
import electronLog from 'electron-log'
import { autoUpdater } from 'electron-updater'
import { action, autorun, computed, makeObservable, observable } from 'mobx'
import { autoUpdatingAvailable } from './Types/Constants'
import { MessageType } from '../../../test/TestIpcMessage'
import { AppState } from '../../application'
import { BackupsManagerInterface } from './Backups/BackupsManagerInterface'
import { StoreKeys } from './Store'
import { updates as str } from './Strings'
import { handleTestMessage } from './Utils/Testing'
import { isTesting } from './Utils/Utils'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function logError(...message: any) {
console.error('updateManager:', ...message)
}
if (isTesting()) {
// eslint-disable-next-line no-var
var notifiedStateUpdate = false
}
export class UpdateState {
latestVersion: string | null = null
enableAutoUpdate: boolean
checkingForUpdate = false
autoUpdateDownloaded = false
lastCheck: Date | null = null
constructor(private appState: AppState) {
this.enableAutoUpdate = autoUpdatingAvailable && appState.store.get(StoreKeys.EnableAutoUpdate)
makeObservable(this, {
latestVersion: observable,
enableAutoUpdate: observable,
checkingForUpdate: observable,
autoUpdateDownloaded: observable,
lastCheck: observable,
updateNeeded: computed,
toggleAutoUpdate: action,
setCheckingForUpdate: action,
autoUpdateHasBeenDownloaded: action,
checkedForUpdate: action,
})
if (isTesting()) {
handleTestMessage(MessageType.UpdateState, () => ({
lastCheck: this.lastCheck,
}))
}
}
get updateNeeded(): boolean {
if (this.latestVersion) {
return compareVersions(this.latestVersion, this.appState.version) === 1
} else {
return false
}
}
toggleAutoUpdate(): void {
this.enableAutoUpdate = !this.enableAutoUpdate
this.appState.store.set(StoreKeys.EnableAutoUpdate, this.enableAutoUpdate)
}
setCheckingForUpdate(checking: boolean): void {
this.checkingForUpdate = checking
}
autoUpdateHasBeenDownloaded(version: string | null): void {
this.autoUpdateDownloaded = true
this.latestVersion = version
}
checkedForUpdate(latestVersion: string | null): void {
this.lastCheck = new Date()
this.latestVersion = latestVersion
}
}
let updatesSetup = false
export function setupUpdates(window: BrowserWindow, appState: AppState, backupsManager: BackupsManagerInterface): void {
if (!autoUpdatingAvailable) {
return
}
if (updatesSetup) {
throw Error('Already set up updates.')
}
const { store } = appState
autoUpdater.logger = electronLog
const updateState = appState.updates
function checkUpdateSafety(): boolean {
let canUpdate: boolean
if (appState.store.get(StoreKeys.BackupsDisabled)) {
canUpdate = true
} else {
canUpdate = updateState.enableAutoUpdate && isLessThanOneHourFromNow(appState.lastBackupDate)
}
autoUpdater.autoInstallOnAppQuit = canUpdate
autoUpdater.autoDownload = canUpdate
return canUpdate
}
autorun(checkUpdateSafety)
const oneHour = 1 * 60 * 60 * 1000
setInterval(checkUpdateSafety, oneHour)
autoUpdater.on('update-downloaded', (info: { version?: string }) => {
window.webContents.send('update-available', null)
updateState.autoUpdateHasBeenDownloaded(info.version || null)
})
autoUpdater.on('error', logError)
autoUpdater.on('update-available', (info: { version?: string }) => {
updateState.checkedForUpdate(info.version || null)
if (updateState.enableAutoUpdate) {
const canUpdate = checkUpdateSafety()
if (!canUpdate) {
backupsManager.performBackup()
}
}
})
autoUpdater.on('update-not-available', (info: { version?: string }) => {
updateState.checkedForUpdate(info.version || null)
})
updatesSetup = true
if (isTesting()) {
handleTestMessage(MessageType.AutoUpdateEnabled, () => store.get(StoreKeys.EnableAutoUpdate))
handleTestMessage(MessageType.CheckForUpdate, () => checkForUpdate(appState, updateState))
// eslint-disable-next-line block-scoped-var
handleTestMessage(MessageType.UpdateManagerNotifiedStateChange, () => notifiedStateUpdate)
} else {
void checkForUpdate(appState, updateState)
}
}
export function openChangelog(state: UpdateState): void {
const url = 'https://github.com/standardnotes/desktop/releases'
if (state.latestVersion) {
void shell.openExternal(`${url}/tag/v${state.latestVersion}`)
} else {
void shell.openExternal(url)
}
}
function quitAndInstall(window: BrowserWindow) {
setTimeout(() => {
// index.js prevents close event on some platforms
window.removeAllListeners('close')
window.close()
autoUpdater.quitAndInstall(false)
}, 0)
}
function isLessThanOneHourFromNow(date: number | null) {
const now = Date.now()
const onHourMs = 1 * 60 * 60 * 1000
return now - (date ?? 0) < onHourMs
}
export async function showUpdateInstallationDialog(parentWindow: BrowserWindow, appState: AppState): Promise<void> {
if (!appState.updates.latestVersion) {
return
}
if (appState.lastBackupDate && isLessThanOneHourFromNow(appState.lastBackupDate)) {
const result = await dialog.showMessageBox(parentWindow, {
type: 'info',
title: str().updateReady.title,
message: str().updateReady.message(appState.updates.latestVersion),
buttons: [str().updateReady.installLater, str().updateReady.quitAndInstall],
cancelId: 0,
})
const buttonIndex = result.response
if (buttonIndex === 1) {
quitAndInstall(parentWindow)
}
} else {
const cancelId = 0
const result = await dialog.showMessageBox({
type: 'warning',
title: str().updateReady.title,
message: str().updateReady.noRecentBackupMessage,
detail: str().updateReady.noRecentBackupDetail(appState.lastBackupDate),
checkboxLabel: str().updateReady.noRecentBackupChecbox,
checkboxChecked: false,
buttons: [str().updateReady.installLater, str().updateReady.quitAndInstall],
cancelId,
})
if (!result.checkboxChecked || result.response === cancelId) {
return
}
quitAndInstall(parentWindow)
}
}
export async function checkForUpdate(appState: AppState, state: UpdateState, userTriggered = false): Promise<void> {
if (!autoUpdatingAvailable) {
return
}
if (state.enableAutoUpdate || userTriggered) {
state.setCheckingForUpdate(true)
try {
const result = await autoUpdater.checkForUpdates()
if (!result) {
return
}
state.checkedForUpdate(result.updateInfo.version)
if (userTriggered) {
let message
if (state.updateNeeded && state.latestVersion) {
message = str().finishedChecking.updateAvailable(state.latestVersion)
} else {
message = str().finishedChecking.noUpdateAvailable(appState.version)
}
void dialog.showMessageBox({
title: str().finishedChecking.title,
message,
})
}
} catch (error) {
if (userTriggered) {
void dialog.showMessageBox({
title: str().finishedChecking.title,
message: str().finishedChecking.error(JSON.stringify(error)),
})
}
} finally {
state.setCheckingForUpdate(false)
}
}
}

View File

@@ -0,0 +1,272 @@
import fs, { PathLike } from 'fs'
import { debounce } from 'lodash'
import path from 'path'
import yauzl from 'yauzl'
import { removeFromArray } from '../Utils/Utils'
import { dialog } from 'electron'
export const FileDoesNotExist = 'ENOENT'
export const FileAlreadyExists = 'EEXIST'
const CrossDeviceLink = 'EXDEV'
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(): Promise<string | undefined> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'],
})
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')
}
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 = await fs.promises.readdir(srcDir)
await ensureDirectoryExists(destDir)
if (isChildOfDir(srcDir, destDir)) {
fileNames = fileNames.filter((name) => {
return !isChildOfDir(destDir, path.join(srcDir, name))
})
removeFromArray(fileNames, path.basename(destDir))
}
return moveFiles(
fileNames.map((fileName) => path.join(srcDir, fileName)),
destDir,
)
}
export async function extractNestedZip(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
}
if (entry.fileName.endsWith('/')) {
/** entry is a directory, skip and read next entry */
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,
/**
* Remove the first element of the entry's path, which is the base
* directory we want to ignore
*/
entry.fileName.substring(entry.fileName.indexOf('/') + 1),
)
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)))))
}
async function moveFile(source: PathLike, destination: PathLike) {
try {
await fs.promises.rename(source, destination)
} catch (error: any) {
if (error.code === CrossDeviceLink) {
/** Fall back to copying and then deleting. */
await fs.promises.copyFile(source, destination)
await fs.promises.unlink(source)
} else {
throw error
}
}
}
/** 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

@@ -0,0 +1,61 @@
import { app, BrowserWindow } from 'electron'
import { AppMessageType, MessageType, TestIPCMessage } from '../../../../test/TestIpcMessage'
import { isTesting } from '../Utils/Utils'
const messageHandlers: {
[key in MessageType]?: (...args: any) => unknown
} = {}
export function handleTestMessage(type: MessageType, handler: (...args: any) => unknown): void {
if (!isTesting()) {
throw Error('Tried to invoke test handler in non-test build.')
}
messageHandlers[type] = handler
}
export function send(type: AppMessageType, data?: unknown): void {
if (!isTesting()) {
return
}
process.send!({ type, data })
}
export function setupTesting(): void {
process.on('message', async (message: TestIPCMessage) => {
const handler = messageHandlers[message.type]
if (!handler) {
process.send!({
id: message.id,
reject: `No handler registered for message type ${MessageType[message.type]}`,
})
return
}
try {
let returnValue = handler(...message.args)
if (returnValue instanceof Promise) {
returnValue = await returnValue
}
process.send!({
id: message.id,
resolve: returnValue,
})
} catch (error: any) {
process.send!({
id: message.id,
reject: error.toString(),
})
}
})
handleTestMessage(MessageType.WindowCount, () => BrowserWindow.getAllWindows().length)
app.on('ready', () => {
setTimeout(() => {
send(AppMessageType.Ready)
}, 200)
})
}

View File

@@ -0,0 +1,44 @@
import { CommandLineArgs } from '../../Shared/CommandLineArgs'
export function isDev(): boolean {
return process.env.NODE_ENV === 'development'
}
export function isTesting(): boolean {
return isDev() && process.argv.includes(CommandLineArgs.Testing)
}
export function isBoolean(arg: unknown): arg is boolean {
return typeof arg === 'boolean'
}
export function ensureIsBoolean(arg: unknown, fallbackValue: boolean): boolean {
if (isBoolean(arg)) {
return arg
}
return fallbackValue
}
export function stringOrNull(arg: unknown): string | null {
if (typeof arg === 'string') {
return arg
}
return null
}
/** Ensures a path's drive letter is lowercase. */
export function lowercaseDriveLetter(filePath: string): string {
return filePath.replace(/^\/[A-Z]:\//, (letter) => letter.toLowerCase())
}
export function timeout(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function removeFromArray<T>(array: T[], toRemove: T): void {
array.splice(array.indexOf(toRemove), 1)
}
export function last<T>(array: T[]): T | undefined {
return array[array.length - 1]
}

View File

@@ -0,0 +1,333 @@
import { BrowserWindow, Rectangle, screen, Shell } from 'electron'
import fs from 'fs'
import { debounce } from 'lodash'
import path from 'path'
import { AppMessageType, MessageType } from '../../../test/TestIpcMessage'
import { AppState } from '../../application'
import { MessageToWebApp } from '../Shared/IpcMessages'
import { createBackupsManager } from './Backups/BackupsManager'
import { BackupsManagerInterface } from './Backups/BackupsManagerInterface'
import { buildContextMenu, createMenuManager } from './Menus/Menus'
import { initializePackageManager } from './Packages/PackageManager'
import { isMac, isWindows } from './Types/Platforms'
import { initializeSearchManager } from './Search/SearchManager'
import { createSpellcheckerManager } from './SpellcheckerManager'
import { Store, StoreKeys } from './Store'
import { handleTestMessage, send } from './Utils/Testing'
import { createTrayManager, TrayManager } from './TrayManager'
import { checkForUpdate, setupUpdates } from './UpdateManager'
import { isTesting, lowercaseDriveLetter } from './Utils/Utils'
import { initializeZoomManager } from './ZoomManager'
import { Paths } from './Types/Paths'
import { clearSensitiveDirectories } from '@standardnotes/electron-clear-data'
import { RemoteBridge } from './Remote/RemoteBridge'
import { Keychain } from './Keychain/Keychain'
import { MenuManagerInterface } from './Menus/MenuManagerInterface'
import { FilesBackupManager } from './FileBackups/FileBackupsManager'
const WINDOW_DEFAULT_WIDTH = 1100
const WINDOW_DEFAULT_HEIGHT = 800
const WINDOW_MIN_WIDTH = 300
const WINDOW_MIN_HEIGHT = 400
export interface WindowState {
window: Electron.BrowserWindow
menuManager: MenuManagerInterface
backupsManager: BackupsManagerInterface
trayManager: TrayManager
}
function hideWindowsTaskbarPreviewThumbnail(window: BrowserWindow) {
if (isWindows()) {
window.setThumbnailClip({ x: 0, y: 0, width: 1, height: 1 })
}
}
export async function createWindowState({
shell,
appState,
appLocale,
teardown,
}: {
shell: Shell
appLocale: string
appState: AppState
teardown: () => void
}): Promise<WindowState> {
const window = await createWindow(appState.store)
const services = await createWindowServices(window, appState, appLocale)
require('@electron/remote/main').enable(window.webContents)
;(global as any).RemoteBridge = new RemoteBridge(
window,
Keychain,
services.backupsManager,
services.packageManager,
services.searchManager,
{
destroySensitiveDirectories: () => {
const restart = true
clearSensitiveDirectories(restart)
},
},
services.menuManager,
services.fileBackupsManager,
)
const shouldOpenUrl = (url: string) => url.startsWith('http') || url.startsWith('mailto')
window.on('closed', teardown)
window.on('show', () => {
void checkForUpdate(appState, appState.updates, false)
hideWindowsTaskbarPreviewThumbnail(window)
})
window.on('focus', () => {
window.webContents.send(MessageToWebApp.WindowFocused, null)
})
window.on('blur', () => {
window.webContents.send(MessageToWebApp.WindowBlurred, null)
services.backupsManager.applicationDidBlur()
})
window.once('ready-to-show', () => {
window.show()
})
window.on('close', (event) => {
if (!appState.willQuitApp && (isMac() || services.trayManager.shouldMinimizeToTray())) {
/**
* On MacOS, closing a window does not quit the app. On Window and Linux,
* it only does if you haven't enabled minimize to tray.
*/
event.preventDefault()
/**
* Handles Mac full screen issue where pressing close results
* in a black screen.
*/
if (window.isFullScreen()) {
window.setFullScreen(false)
}
window.hide()
}
})
window.webContents.session.setSpellCheckerDictionaryDownloadURL('https://dictionaries.standardnotes.org/9.4.4/')
/** handle link clicks */
window.webContents.on('new-window', (event, url) => {
if (shouldOpenUrl(url)) {
void shell.openExternal(url)
}
event.preventDefault()
})
/**
* handle link clicks (this event is fired instead of 'new-window' when
* target is not set to _blank, such as with window.location.assign)
*/
window.webContents.on('will-navigate', (event, url) => {
/** Check for windowUrl equality in the case of window.reload() calls. */
if (fileUrlsAreEqual(url, appState.startUrl)) {
return
}
if (shouldOpenUrl(url)) {
void shell.openExternal(url)
}
event.preventDefault()
})
window.webContents.on('context-menu', (_event, params) => {
buildContextMenu(window.webContents, params).popup()
})
return {
window,
...services,
}
}
async function createWindow(store: Store): Promise<Electron.BrowserWindow> {
const useSystemMenuBar = store.get(StoreKeys.UseSystemMenuBar)
const position = await getPreviousWindowPosition()
const window = new BrowserWindow({
...position.bounds,
minWidth: WINDOW_MIN_WIDTH,
minHeight: WINDOW_MIN_HEIGHT,
show: false,
icon: path.join(__dirname, '/icon/Icon-512x512.png'),
titleBarStyle: isMac() || useSystemMenuBar ? 'hiddenInset' : undefined,
frame: isMac() ? false : useSystemMenuBar,
webPreferences: {
spellcheck: true,
nodeIntegration: isTesting(),
contextIsolation: true,
preload: Paths.preloadJs,
},
})
if (position.isFullScreen) {
window.setFullScreen(true)
}
if (position.isMaximized) {
window.maximize()
}
persistWindowPosition(window)
if (isTesting()) {
handleTestMessage(MessageType.SpellCheckerLanguages, () => window.webContents.session.getSpellCheckerLanguages())
handleTestMessage(MessageType.SetLocalStorageValue, async (key, value) => {
await window.webContents.executeJavaScript(`localStorage.setItem("${key}", "${value}")`)
window.webContents.session.flushStorageData()
})
handleTestMessage(MessageType.SignOut, () => window.webContents.executeJavaScript('window.device.onSignOut(false)'))
window.webContents.once('did-finish-load', () => {
send(AppMessageType.WindowLoaded)
})
}
return window
}
async function createWindowServices(window: Electron.BrowserWindow, appState: AppState, appLocale: string) {
const packageManager = await initializePackageManager(window.webContents)
const searchManager = initializeSearchManager(window.webContents)
initializeZoomManager(window, appState.store)
const backupsManager = createBackupsManager(window.webContents, appState)
const updateManager = setupUpdates(window, appState, backupsManager)
const trayManager = createTrayManager(window, appState.store)
const spellcheckerManager = createSpellcheckerManager(appState.store, window.webContents, appLocale)
if (isTesting()) {
handleTestMessage(MessageType.SpellCheckerManager, () => spellcheckerManager)
}
const menuManager = createMenuManager({
appState,
window,
backupsManager,
trayManager,
store: appState.store,
spellcheckerManager,
})
const fileBackupsManager = new FilesBackupManager(appState)
return {
backupsManager,
updateManager,
trayManager,
spellcheckerManager,
menuManager,
packageManager,
searchManager,
fileBackupsManager,
}
}
/**
* Check file urls for equality by decoding components
* In packaged app, spaces in navigation events urls can contain %20
* but not in windowUrl.
*/
function fileUrlsAreEqual(a: string, b: string): boolean {
/** Catch exceptions in case of malformed urls. */
try {
/**
* Craft URL objects to eliminate production URL values that can
* contain "#!/" suffixes (on Windows)
*/
let aPath = new URL(decodeURIComponent(a)).pathname
let bPath = new URL(decodeURIComponent(b)).pathname
if (isWindows()) {
/** On Windows, drive letter casing is inconsistent */
aPath = lowercaseDriveLetter(aPath)
bPath = lowercaseDriveLetter(bPath)
}
return aPath === bPath
} catch (error) {
return false
}
}
interface WindowPosition {
bounds: Rectangle
isMaximized: boolean
isFullScreen: boolean
}
async function getPreviousWindowPosition() {
let position: WindowPosition
try {
position = JSON.parse(await fs.promises.readFile(path.join(Paths.userDataDir, 'window-position.json'), 'utf8'))
} catch (e) {
return {
bounds: {
width: WINDOW_DEFAULT_WIDTH,
height: WINDOW_DEFAULT_HEIGHT,
},
}
}
const options: Partial<Rectangle> = {}
const bounds = position.bounds
if (bounds) {
/** Validate coordinates. Keep them if the window can fit on a screen */
const area = screen.getDisplayMatching(bounds).workArea
if (
bounds.x >= area.x &&
bounds.y >= area.y &&
bounds.x + bounds.width <= area.x + area.width &&
bounds.y + bounds.height <= area.y + area.height
) {
options.x = bounds.x
options.y = bounds.y
}
if (bounds.width <= area.width || bounds.height <= area.height) {
options.width = bounds.width
options.height = bounds.height
}
}
return {
isMaximized: position.isMaximized,
isFullScreen: position.isFullScreen,
bounds: {
width: WINDOW_DEFAULT_WIDTH,
height: WINDOW_DEFAULT_HEIGHT,
...options,
},
}
}
function persistWindowPosition(window: BrowserWindow) {
let writingToDisk = false
const saveWindowBounds = debounce(async () => {
const position: WindowPosition = {
bounds: window.getNormalBounds(),
isMaximized: window.isMaximized(),
isFullScreen: window.isFullScreen(),
}
if (writingToDisk) {
return
}
writingToDisk = true
try {
await fs.promises.writeFile(Paths.windowPositionJson, JSON.stringify(position), 'utf-8')
} catch (error) {
console.error('Could not write to window-position.json', error)
} finally {
writingToDisk = false
}
}, 500)
window.on('resize', saveWindowBounds)
window.on('move', saveWindowBounds)
}

View File

@@ -0,0 +1,16 @@
import { BrowserWindow } from 'electron'
import { Store, StoreKeys } from './Store'
export function initializeZoomManager(window: BrowserWindow, store: Store): void {
window.webContents.on('dom-ready', () => {
const zoomFactor = store.get(StoreKeys.ZoomFactor)
if (zoomFactor) {
window.webContents.zoomFactor = zoomFactor
}
})
window.on('close', () => {
const zoomFactor = window.webContents.zoomFactor
store.set(StoreKeys.ZoomFactor, zoomFactor)
})
}

View File

@@ -0,0 +1,52 @@
import { Component } from '../Main/Packages/PackageManagerInterface'
import { FileBackupsDevice } from '@web/Application/Device/DesktopSnjsExports'
export interface CrossProcessBridge extends FileBackupsDevice {
get extServerHost(): string
get useNativeKeychain(): boolean
get rendererPath(): string
get isMacOS(): boolean
get appVersion(): string
get useSystemMenuBar(): boolean
closeWindow(): void
minimizeWindow(): void
maximizeWindow(): void
unmaximizeWindow(): void
isWindowMaximized(): boolean
getKeychainValue(): Promise<unknown>
setKeychainValue: (value: unknown) => Promise<void>
clearKeychainValue(): Promise<boolean>
localBackupsCount(): Promise<number>
viewlocalBackups(): void
deleteLocalBackups(): Promise<void>
saveDataBackup(data: unknown): void
displayAppMenu(): void
syncComponents(components: Component[]): void
onMajorDataChange(): void
onSearch(text: string): void
onInitialDataLoad(): void
destroyAllData(): void
}

View File

@@ -0,0 +1,154 @@
import { WebOrDesktopDevice } from '@web/Application/Device/WebOrDesktopDevice'
import { Component } from '../Main/Packages/PackageManagerInterface'
import {
RawKeychainValue,
Environment,
DesktopDeviceInterface,
FileBackupsMapping,
} from '@web/Application/Device/DesktopSnjsExports'
import { CrossProcessBridge } from './CrossProcessBridge'
const FallbackLocalStorageKey = 'keychain'
export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceInterface {
public environment: Environment.Desktop = Environment.Desktop
constructor(
private remoteBridge: CrossProcessBridge,
private useNativeKeychain: boolean,
public extensionsServerHost: string,
appVersion: string,
) {
super(appVersion)
}
async getKeychainValue() {
if (this.useNativeKeychain) {
const keychainValue = await this.remoteBridge.getKeychainValue()
return keychainValue
} else {
const value = window.localStorage.getItem(FallbackLocalStorageKey)
if (value) {
return JSON.parse(value)
}
}
}
async setKeychainValue(value: RawKeychainValue) {
if (this.useNativeKeychain) {
await this.remoteBridge.setKeychainValue(value)
} else {
window.localStorage.setItem(FallbackLocalStorageKey, JSON.stringify(value))
}
}
async clearRawKeychainValue() {
if (this.useNativeKeychain) {
await this.remoteBridge.clearKeychainValue()
} else {
window.localStorage.removeItem(FallbackLocalStorageKey)
}
}
syncComponents(components: Component[]) {
this.remoteBridge.syncComponents(components)
}
onMajorDataChange() {
this.remoteBridge.onMajorDataChange()
}
onSearch(text: string) {
this.remoteBridge.onSearch(text)
}
onInitialDataLoad() {
this.remoteBridge.onInitialDataLoad()
}
async clearAllDataFromDevice(workspaceIdentifiers: string[]): Promise<{ killsApplication: boolean }> {
await super.clearAllDataFromDevice(workspaceIdentifiers)
this.remoteBridge.destroyAllData()
return { killsApplication: true }
}
async downloadBackup() {
const receiver = window.webClient
receiver.didBeginBackup()
try {
const data = await receiver.requestBackupFile()
if (data) {
this.remoteBridge.saveDataBackup(data)
} else {
receiver.didFinishBackup(false)
}
} catch (error) {
console.error(error)
receiver.didFinishBackup(false)
}
}
async localBackupsCount() {
return this.remoteBridge.localBackupsCount()
}
viewlocalBackups() {
this.remoteBridge.viewlocalBackups()
}
async deleteLocalBackups() {
return this.remoteBridge.deleteLocalBackups()
}
public isFilesBackupsEnabled(): Promise<boolean> {
return this.remoteBridge.isFilesBackupsEnabled()
}
public enableFilesBackups(): Promise<void> {
return this.remoteBridge.enableFilesBackups()
}
public disableFilesBackups(): Promise<void> {
return this.remoteBridge.disableFilesBackups()
}
public changeFilesBackupsLocation(): Promise<string | undefined> {
return this.remoteBridge.changeFilesBackupsLocation()
}
public getFilesBackupsLocation(): Promise<string> {
return this.remoteBridge.getFilesBackupsLocation()
}
async getFilesBackupsMappingFile(): Promise<FileBackupsMapping> {
return this.remoteBridge.getFilesBackupsMappingFile()
}
async openFilesBackupsLocation(): Promise<void> {
return this.remoteBridge.openFilesBackupsLocation()
}
async saveFilesBackupsFile(
uuid: string,
metaFile: string,
downloadRequest: {
chunkSizes: number[]
valetToken: string
url: string
},
): Promise<'success' | 'failed'> {
return this.remoteBridge.saveFilesBackupsFile(uuid, metaFile, downloadRequest)
}
async performHardReset(): Promise<void> {
console.error('performHardReset is not yet implemented')
}
isDeviceDestroyed(): boolean {
return false
}
}

View File

@@ -0,0 +1,42 @@
import { MessageToWebApp } from '../Shared/IpcMessages'
const { ipcRenderer } = require('electron')
const path = require('path')
const rendererPath = path.join('file://', __dirname, '/renderer.js')
const RemoteBridge = require('@electron/remote').getGlobal('RemoteBridge')
const { contextBridge } = require('electron')
process.once('loaded', function () {
contextBridge.exposeInMainWorld('electronRemoteBridge', RemoteBridge.exposableValue)
listenForIpcEventsFromMainProcess()
})
function listenForIpcEventsFromMainProcess() {
const sendMessageToRenderProcess = (message: string, payload = {}) => {
window.postMessage(JSON.stringify({ message, data: payload }), rendererPath)
}
ipcRenderer.on(MessageToWebApp.UpdateAvailable, function (_event, data) {
sendMessageToRenderProcess(MessageToWebApp.UpdateAvailable, data)
})
ipcRenderer.on(MessageToWebApp.PerformAutomatedBackup, function (_event, data) {
sendMessageToRenderProcess(MessageToWebApp.PerformAutomatedBackup, data)
})
ipcRenderer.on(MessageToWebApp.FinishedSavingBackup, function (_event, data) {
sendMessageToRenderProcess(MessageToWebApp.FinishedSavingBackup, data)
})
ipcRenderer.on(MessageToWebApp.WindowBlurred, function (_event, data) {
sendMessageToRenderProcess(MessageToWebApp.WindowBlurred, data)
})
ipcRenderer.on(MessageToWebApp.WindowFocused, function (_event, data) {
sendMessageToRenderProcess(MessageToWebApp.WindowFocused, data)
})
ipcRenderer.on(MessageToWebApp.InstallComponentComplete, function (_event, data) {
sendMessageToRenderProcess(MessageToWebApp.InstallComponentComplete, data)
})
}

View File

@@ -0,0 +1,164 @@
import { DesktopDevice } from './DesktopDevice'
import { MessageToWebApp } from '../Shared/IpcMessages'
import { DesktopClientRequiresWebMethods } from '@web/Application/Device/DesktopSnjsExports'
import { StartApplication } from '@web/Application/Device/StartApplication'
import { CrossProcessBridge } from './CrossProcessBridge'
declare const DEFAULT_SYNC_SERVER: string
declare const WEBSOCKET_URL: string
declare const ENABLE_UNFINISHED_FEATURES: string
declare const PURCHASE_URL: string
declare const PLANS_URL: string
declare const DASHBOARD_URL: string
declare global {
interface Window {
device: DesktopDevice
electronRemoteBridge: CrossProcessBridge
dashboardUrl: string
webClient: DesktopClientRequiresWebMethods
electronAppVersion: string
enableUnfinishedFeatures: boolean
plansUrl: string
purchaseUrl: string
startApplication: StartApplication
zip: any
}
}
const loadWindowVarsRequiredByWebApp = () => {
window.dashboardUrl = DASHBOARD_URL
window.enableUnfinishedFeatures = ENABLE_UNFINISHED_FEATURES === 'true'
window.plansUrl = PLANS_URL
window.purchaseUrl = PURCHASE_URL
}
const loadAndStartApplication = async () => {
const remoteBridge: CrossProcessBridge = window.electronRemoteBridge
await configureWindow(remoteBridge)
window.device = await createDesktopDevice(remoteBridge)
window.startApplication(DEFAULT_SYNC_SERVER, window.device, window.enableUnfinishedFeatures, WEBSOCKET_URL)
listenForMessagesSentFromMainToPreloadToUs(window.device)
}
window.onload = () => {
loadWindowVarsRequiredByWebApp()
void loadAndStartApplication()
}
/** @returns whether the keychain structure is up to date or not */
async function migrateKeychain(remoteBridge: CrossProcessBridge): Promise<boolean> {
if (!remoteBridge.useNativeKeychain) {
/** User chose not to use keychain, do not migrate. */
return false
}
const key = 'keychain'
const localStorageValue = window.localStorage.getItem(key)
if (localStorageValue) {
/** Migrate to native keychain */
console.warn('Migrating keychain from localStorage to native keychain.')
window.localStorage.removeItem(key)
await remoteBridge.setKeychainValue(JSON.parse(localStorageValue))
}
return true
}
async function createDesktopDevice(remoteBridge: CrossProcessBridge): Promise<DesktopDevice> {
const useNativeKeychain = await migrateKeychain(remoteBridge)
const extensionsServerHost = remoteBridge.extServerHost
const appVersion = remoteBridge.appVersion
return new DesktopDevice(remoteBridge, useNativeKeychain, extensionsServerHost, appVersion)
}
async function configureWindow(remoteBridge: CrossProcessBridge) {
const isMacOS = remoteBridge.isMacOS
const useSystemMenuBar = remoteBridge.useSystemMenuBar
const appVersion = remoteBridge.appVersion
window.electronAppVersion = appVersion
/*
Title bar events
*/
document.getElementById('menu-btn')!.addEventListener('click', () => {
remoteBridge.displayAppMenu()
})
document.getElementById('min-btn')!.addEventListener('click', () => {
remoteBridge.minimizeWindow()
})
document.getElementById('max-btn')!.addEventListener('click', async () => {
if (remoteBridge.isWindowMaximized()) {
remoteBridge.unmaximizeWindow()
} else {
remoteBridge.maximizeWindow()
}
})
document.getElementById('close-btn')!.addEventListener('click', () => {
remoteBridge.closeWindow()
})
// For Mac inset window
const sheet = document.styleSheets[0]
if (isMacOS) {
sheet.insertRule('#navigation { padding-top: 25px !important; }', sheet.cssRules.length)
}
if (isMacOS || useSystemMenuBar) {
// !important is important here because #desktop-title-bar has display: flex.
sheet.insertRule('#desktop-title-bar { display: none !important; }', sheet.cssRules.length)
} else {
/* Use custom title bar. Take the sn-titlebar-height off of
the app content height so its not overflowing */
sheet.insertRule('body { padding-top: var(--sn-desktop-titlebar-height); }', sheet.cssRules.length)
sheet.insertRule(
`.main-ui-view { height: calc(100vh - var(--sn-desktop-titlebar-height)) !important;
min-height: calc(100vh - var(--sn-desktop-titlebar-height)) !important; }`,
sheet.cssRules.length,
)
}
}
function listenForMessagesSentFromMainToPreloadToUs(device: DesktopDevice) {
window.addEventListener('message', async (event) => {
// We don't have access to the full file path.
if (event.origin !== 'file://') {
return
}
let payload
try {
payload = JSON.parse(event.data)
} catch (e) {
// message doesn't belong to us
return
}
const receiver = window.webClient
const message = payload.message
const data = payload.data
if (message === MessageToWebApp.WindowBlurred) {
receiver.windowLostFocus()
} else if (message === MessageToWebApp.WindowFocused) {
receiver.windowGainedFocus()
} else if (message === MessageToWebApp.InstallComponentComplete) {
receiver.onComponentInstallationComplete(data.component, data.error)
} else if (message === MessageToWebApp.UpdateAvailable) {
receiver.updateAvailable()
} else if (message === MessageToWebApp.PerformAutomatedBackup) {
void device.downloadBackup()
} else if (message === MessageToWebApp.FinishedSavingBackup) {
receiver.didFinishBackup(data.success)
}
})
}

View File

@@ -0,0 +1,22 @@
/* eslint-disable no-undef */
const { ipcRenderer } = require('electron')
import { MessageToMainProcess } from '../Shared/IpcMessages'
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('use-storage-button').addEventListener('click', () => {
ipcRenderer.send(MessageToMainProcess.UseLocalstorageForKeychain)
})
document.getElementById('quit-button').addEventListener('click', () => {
ipcRenderer.send(MessageToMainProcess.Quit)
})
const learnMoreButton = document.getElementById('learn-more')
learnMoreButton.addEventListener('click', (event) => {
ipcRenderer.send(MessageToMainProcess.LearnMoreAboutKeychainAccess)
event.preventDefault()
const moreInfo = document.getElementById('more-info')
moreInfo.style.display = 'block'
learnMoreButton.style.display = 'none'
})
})

View File

@@ -0,0 +1,4 @@
export const CommandLineArgs = {
Testing: '--testing-INSECURE',
UserDataPath: '--experimental-user-data-path',
}

View File

@@ -0,0 +1,14 @@
export enum MessageToWebApp {
UpdateAvailable = 'update-available',
PerformAutomatedBackup = 'download-backup',
FinishedSavingBackup = 'finished-saving-backup',
WindowBlurred = 'window-blurred',
WindowFocused = 'window-focused',
InstallComponentComplete = 'install-component-complete',
}
export enum MessageToMainProcess {
UseLocalstorageForKeychain = 'UseLocalstorageForKeychain',
LearnMoreAboutKeychainAccess = 'LearnMoreAboutKeychainAccess',
Quit = 'Quit',
}