├── .env.sample ├── .eslintrc ├── .github ├── FUNDING.yml ├── codeql │ ├── codeql-config.yml │ └── custom-queries │ │ └── javascript │ │ └── qlpack.yml └── workflows │ ├── codeql-analysis.yml │ ├── dev.yml │ ├── pr.yml │ └── prod.yml ├── .gitignore ├── .gitmodules ├── .husky └── pre-commit ├── .mocharc.json ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── SECURITY.md ├── app ├── @types │ └── modules.ts ├── application.ts ├── enableExperimentalWebFeatures.ts ├── grantLinuxPasswordsAccess.html ├── icon │ ├── Icon-256x256.png │ └── Icon-512x512.png ├── index.html ├── index.ts ├── javascripts │ ├── Main │ │ ├── Backups │ │ │ ├── BackupsManager.ts │ │ │ └── BackupsManagerInterface.ts │ │ ├── ExtensionsServer.ts │ │ ├── FileBackups │ │ │ ├── FileBackupsManager.ts │ │ │ ├── FileDownloader.ts │ │ │ └── FileNetworking.ts │ │ ├── Keychain │ │ │ ├── Keychain.ts │ │ │ └── KeychainInterface.ts │ │ ├── Menus │ │ │ ├── MenuManagerInterface.ts │ │ │ └── Menus.ts │ │ ├── Packages │ │ │ ├── Networking.ts │ │ │ ├── PackageManager.ts │ │ │ └── PackageManagerInterface.ts │ │ ├── Remote │ │ │ ├── DataInterface.ts │ │ │ └── RemoteBridge.ts │ │ ├── Search │ │ │ ├── SearchManager.ts │ │ │ └── SearchManagerInterface.ts │ │ ├── SpellcheckerManager.ts │ │ ├── Store.ts │ │ ├── Strings │ │ │ ├── english.ts │ │ │ ├── french.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── TrayManager.ts │ │ ├── Types │ │ │ ├── Constants.ts │ │ │ ├── Paths.ts │ │ │ └── Platforms.ts │ │ ├── UpdateManager.ts │ │ ├── Utils │ │ │ ├── FileUtils.ts │ │ │ ├── Testing.ts │ │ │ └── Utils.ts │ │ ├── Window.ts │ │ └── ZoomManager.ts │ ├── Renderer │ │ ├── CrossProcessBridge.ts │ │ ├── DesktopDevice.ts │ │ ├── Preload.ts │ │ ├── Renderer.ts │ │ └── grantLinuxPasswordsAccess.js │ └── Shared │ │ ├── CommandLineArgs.ts │ │ └── IpcMessages.ts ├── package.json ├── stylesheets │ └── renderer.css ├── vendor │ └── zip │ │ ├── deflate.js │ │ ├── inflate.js │ │ ├── z-worker.js │ │ └── zip.js └── yarn.lock ├── babel.config.js ├── build ├── entitlements.mac.inherit.plist ├── icon.icns ├── icon.ico ├── icon.iconset │ ├── icon_128x128.png │ ├── icon_128x128@2x.png │ ├── icon_16x16.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_256x256@2x.png │ ├── icon_32x32.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ └── icon_512x512@2x.png ├── icon │ └── Icon-512x512.png └── installer.nsh ├── commitlint.config.js ├── dev-app-update.yml ├── package.json ├── scripts ├── afterSignHook.js ├── build.mjs ├── change-version.mjs ├── create-draft-release.mjs ├── fix-mac-zip.js ├── sums.mjs └── utils.mjs ├── test ├── TestIpcMessage.ts ├── backupsManager.spec.ts ├── data │ └── zip-file.zip ├── driver.ts ├── extServer.spec.ts ├── fakePaths.ts ├── fileUtils.spec.ts ├── menus.spec.ts ├── networking.spec.ts ├── packageManager.spec.ts ├── spellcheckerManager.spec.ts ├── storage.spec.ts ├── testUtils.ts ├── updates.spec.ts ├── utils.spec.ts └── window.spec.ts ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js ├── webpack.prod.js └── yarn.lock /.env.sample: -------------------------------------------------------------------------------- 1 | DEFAULT_SYNC_SERVER=https://api.standardnotes.com -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "commonjs": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "project": "./tsconfig.json", 15 | "extraFileExtensions": [".mjs"] 16 | }, 17 | "ignorePatterns": ["test", "scripts", ".eslintrc", "tsconfig.json", "node_modules"], 18 | "rules": { 19 | /** Style */ 20 | "quotes": ["error", "single", { "avoidEscape": true }], 21 | 22 | /** Preferences */ 23 | "no-console": "off", 24 | "accessor-pairs": 0, 25 | "no-nonoctal-decimal-escape": 0, 26 | "no-unsafe-optional-chaining": 0, 27 | "@typescript-eslint/explicit-function-return-type": 0, 28 | "@typescript-eslint/no-use-before-define": ["error", "nofunc"], 29 | "@typescript-eslint/no-non-null-assertion": 0, 30 | "@typescript-eslint/no-explicit-any": 0, 31 | 32 | "@typescript-eslint/no-var-requires": 0, 33 | 34 | /** 35 | * The following rules are disabled because they are already implemented 36 | * in the TypeScript configuration, or vice-versa 37 | */ 38 | "no-unused-vars": 0, 39 | "no-useless-constructor": 0, 40 | "no-unused-expressions": 0, 41 | "@typescript-eslint/camelcase": 0 42 | }, 43 | "plugins": ["@typescript-eslint"], 44 | "globals": { 45 | "zip": true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [standardnotes] 4 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "Custom CodeQL Config" 2 | 3 | queries: 4 | - uses: security-and-quality 5 | - uses: ./.github/codeql/custom-queries/javascript 6 | 7 | paths: 8 | - app 9 | 10 | paths-ignore: 11 | - build 12 | - dist 13 | - node_modules 14 | - test 15 | - web 16 | - app/dist 17 | - app/node_modules 18 | -------------------------------------------------------------------------------- /.github/codeql/custom-queries/javascript/qlpack.yml: -------------------------------------------------------------------------------- 1 | name: custom-javascript-queries 2 | version: 0.0.0 3 | libraryPathDependencies: 4 | - codeql-javascript 5 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ develop, main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ develop ] 20 | schedule: 21 | - cron: '41 17 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | config-file: ./.github/codeql/codeql-config.yml 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: Dev 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '14.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: yarn setup 18 | - run: node scripts/build.mjs appimage-x64 19 | - uses: actions/upload-artifact@v2 20 | with: 21 | name: 'AppImage' 22 | path: dist/*.AppImage 23 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Dev 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - develop 7 | - main 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '14.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: yarn setup 19 | - name: Prettier 20 | run: yarn lint:formatting 21 | - name: Typescript 22 | run: yarn lint:types 23 | - name: ESLint 24 | run: yarn lint:eslint 25 | -------------------------------------------------------------------------------- /.github/workflows/prod.yml: -------------------------------------------------------------------------------- 1 | name: Prod 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '14.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: yarn setup 18 | - name: Prettier 19 | run: yarn lint:formatting 20 | - name: Typescript 21 | run: yarn lint:types 22 | - name: ESLint 23 | run: yarn lint:eslint 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | /dist/ 4 | npm-debug.log 5 | test/data/tmp/ 6 | 7 | /app/dist 8 | .vscode 9 | .idea 10 | .env 11 | .env.production 12 | .env.development 13 | 14 | codeqldb 15 | yarn-error.log -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "web"] 2 | path = web 3 | url = https://github.com/standardnotes/web.git 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": "test/*.spec.ts", 4 | "require": "ts-node/register/transpile-only" 5 | } 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | runtime = electron 2 | target = 5.0.10 3 | target_arch = x64 4 | disturl = https://atom.io/download/atom-shell 5 | export npm_config_runtime=electron 6 | export npm_config_build_from_source=true 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.17.3 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | app/dist/ 4 | app/vendor/ 5 | test/data/tmp/ 6 | web/ 7 | .github 8 | codeqldb 9 | *.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Standard Notes 2 | 3 |
4 | 5 | [![latest release version](https://img.shields.io/github/v/release/standardnotes/desktop)](https://github.com/standardnotes/desktop/releases) 6 | [![License](https://img.shields.io/github/license/standardnotes/desktop?color=blue)](https://github.com/standardnotes/desktop/blob/master/LICENSE) 7 | [![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.com/slack) 8 | [![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes) 9 | 10 |
11 | 12 | This application makes use of the core JS/CSS/HTML code found in the [web repo](https://github.com/standardnotes/web). For issues related to the actual app experience, please post issues in the web repo. 13 | 14 | ## Running Locally 15 | 16 | Make sure [Yarn](https://classic.yarnpkg.com/en/) is installed on your system. 17 | 18 | ```bash 19 | yarn setup 20 | yarn build:web # Or `yarn dev:web` 21 | yarn dev 22 | 23 | # In another terminal 24 | yarn start 25 | ``` 26 | 27 | We use [commitlint](https://github.com/conventional-changelog/commitlint) to validate commit messages. 28 | Before making a pull request, make sure to check the output of the following commands: 29 | 30 | ```bash 31 | yarn lint 32 | yarn test # Make sure to start `yarn dev` before running the tests, and quit any running Standard Notes applications so they don't conflict. 33 | ``` 34 | 35 | Pull requests should target the `develop` branch. 36 | 37 | ### Installing dependencies 38 | 39 | To determine where to install a dependency: 40 | 41 | - If it is only required for building, install it in `package.json`'s `devDependencies` 42 | - If it is required at runtime but can be packaged by webpack, install it in `package.json`'s `dependencies`. 43 | - If it must be distributed as a node module (not packaged by webpack), install it in `app/package.json`'s `dependencies` 44 | - Also make sure to declare it as an external commonjs dependency in `webpack.common.js`. 45 | 46 | ## Building 47 | 48 | Build for all platforms: 49 | 50 | - `yarn release` 51 | 52 | ## Building natively on arm64 53 | 54 | Building arm64 releases on amd64 systems is only possible with AppImage, Debian and universal "dir" targets. 55 | 56 | Building arm64 releases natively on arm64 systems requires some additional preparation: 57 | 58 | - `export npm_config_target_arch=arm64` 59 | - `export npm_config_arch=arm64` 60 | 61 | A native `fpm` installation is needed for Debian builds. `fpm` needs to be available in `$PATH`, which can be achieved by running 62 | 63 | - `gem install fpm --no-document` 64 | 65 | and making sure `$GEM_HOME/bin` is added to `$PATH`. 66 | 67 | Snap releases also require a working snapcraft / `snapd` installation. 68 | 69 | Building can then be done by running: 70 | 71 | - `yarn setup` 72 | 73 | Followed by 74 | 75 | - `node scripts/build.mjs deb-arm64` 76 | 77 | ## Installation 78 | 79 | On Linux, download the latest AppImage from the [Releases](https://github.com/standardnotes/desktop/releases/latest) page, and give it executable permission: 80 | 81 | `chmod u+x standard-notes*.AppImage` 82 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Thank you for your work in helping keep Standard Notes safe and secure. If you believe you've found a security issue in our product, we encourage you to notify us. We welcome working with you to resolve the issue promptly. 2 | 3 | # Disclosure Policy 4 | 5 | - Let us know as soon as possible upon discovery of a potential security issue, and we'll make every 6 | effort to quickly resolve the issue. Please email [security@standardnotes.com](mailto:security@standardnotes.com) for a direct response. 7 | - Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a 8 | third-party. We may publicly disclose the issue before resolving it, if appropriate. 9 | - Make a good faith effort to avoid privacy violations, destruction of data, and interruption or 10 | degradation of our service. Only interact with accounts you own or with explicit permission of the 11 | account holder. 12 | 13 | # In-scope 14 | 15 | - Security issues in any current release of Standard Notes. Our product downloads are available on our homepage at https://standardnotes.com, and our source code is available at https://github.com/standardnotes. 16 | 17 | # Exclusions 18 | 19 | The following bug classes are out-of scope: 20 | 21 | - Bugs that are already reported on any of Standard Notes' issue trackers (https://github.com/standardnotes), or that we already know of. 22 | - Issues in an upstream software dependency (ex: Electron, React Native) which are already reported to the upstream maintainer. 23 | - Attacks requiring physical access to a user's device. 24 | - Self-XSS 25 | - Issues related to software or protocols not under SN's control 26 | - Vulnerabilities in outdated versions of Standard Notes 27 | - Missing security best practices that do not directly lead to a vulnerability 28 | - Issues that do not have any impact on the general public 29 | 30 | While researching, we'd like to ask you to refrain from: 31 | 32 | - Denial of service 33 | - Spamming 34 | - Social engineering (including phishing) of Standard Notes' staff or contractors 35 | - Any physical attempts against Standard Notes' property or data centers 36 | 37 | Thank you for helping keep Standard Notes secure! 38 | -------------------------------------------------------------------------------- /app/@types/modules.ts: -------------------------------------------------------------------------------- 1 | declare module '*.html' { 2 | const content: string 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /app/application.ts: -------------------------------------------------------------------------------- 1 | import { App, Shell } from 'electron' 2 | import { createExtensionsServer } from './javascripts/Main/ExtensionsServer' 3 | import { isLinux, isMac, isWindows } from './javascripts/Main/Types/Platforms' 4 | import { Store, StoreKeys } from './javascripts/Main/Store' 5 | import { AppName, initializeStrings } from './javascripts/Main/Strings' 6 | import { createWindowState, WindowState } from './javascripts/Main/Window' 7 | import { Keychain } from './javascripts/Main/Keychain/Keychain' 8 | import { isDev, isTesting } from './javascripts/Main/Utils/Utils' 9 | import { Urls, Paths } from './javascripts/Main/Types/Paths' 10 | import { action, makeObservable, observable } from 'mobx' 11 | import { UpdateState } from './javascripts/Main/UpdateManager' 12 | import { handleTestMessage } from './javascripts/Main/Utils/Testing' 13 | import { MessageType } from '../test/TestIpcMessage' 14 | 15 | const deepLinkScheme = 'standardnotes' 16 | 17 | export class AppState { 18 | readonly version: string 19 | readonly store: Store 20 | readonly startUrl = Urls.indexHtml 21 | readonly isPrimaryInstance: boolean 22 | public willQuitApp = false 23 | public lastBackupDate: number | null = null 24 | public windowState?: WindowState 25 | public deepLinkUrl?: string 26 | public readonly updates: UpdateState 27 | 28 | constructor(app: Electron.App) { 29 | this.version = app.getVersion() 30 | this.store = new Store(Paths.userDataDir) 31 | this.isPrimaryInstance = app.requestSingleInstanceLock() 32 | makeObservable(this, { 33 | lastBackupDate: observable, 34 | setBackupCreationDate: action, 35 | }) 36 | this.updates = new UpdateState(this) 37 | 38 | if (isTesting()) { 39 | handleTestMessage(MessageType.AppStateCall, (method, ...args) => { 40 | ;(this as any)[method](...args) 41 | }) 42 | } 43 | } 44 | 45 | setBackupCreationDate(date: number | null): void { 46 | this.lastBackupDate = date 47 | } 48 | } 49 | 50 | export function initializeApplication(args: { app: Electron.App; ipcMain: Electron.IpcMain; shell: Shell }): void { 51 | const { app } = args 52 | 53 | app.name = AppName 54 | 55 | const state = new AppState(app) 56 | setupDeepLinking(app) 57 | registerSingleInstanceHandler(app, state) 58 | registerAppEventListeners({ 59 | ...args, 60 | state, 61 | }) 62 | 63 | if (isDev()) { 64 | /** Expose the app's state as a global variable. Useful for debugging */ 65 | ;(global as any).appState = state 66 | } 67 | } 68 | 69 | function focusWindow(appState: AppState) { 70 | const window = appState.windowState?.window 71 | 72 | if (window) { 73 | if (!window.isVisible()) { 74 | window.show() 75 | } 76 | if (window.isMinimized()) { 77 | window.restore() 78 | } 79 | window.focus() 80 | } 81 | } 82 | 83 | function registerSingleInstanceHandler(app: Electron.App, appState: AppState) { 84 | app.on('second-instance', (_event: Event, argv: string[]) => { 85 | if (isWindows()) { 86 | appState.deepLinkUrl = argv.find((arg) => arg.startsWith(deepLinkScheme)) 87 | } 88 | 89 | /* Someone tried to run a second instance, we should focus our window. */ 90 | focusWindow(appState) 91 | }) 92 | } 93 | 94 | function registerAppEventListeners(args: { 95 | app: Electron.App 96 | ipcMain: Electron.IpcMain 97 | shell: Shell 98 | state: AppState 99 | }) { 100 | const { app, state } = args 101 | 102 | app.on('window-all-closed', () => { 103 | if (!isMac()) { 104 | app.quit() 105 | } 106 | }) 107 | 108 | app.on('before-quit', () => { 109 | state.willQuitApp = true 110 | }) 111 | 112 | app.on('activate', () => { 113 | const windowState = state.windowState 114 | if (!windowState) return 115 | windowState.window.show() 116 | }) 117 | 118 | app.on('open-url', (_event, url) => { 119 | state.deepLinkUrl = url 120 | focusWindow(state) 121 | }) 122 | 123 | app.on('ready', () => { 124 | if (!state.isPrimaryInstance) { 125 | console.warn('Quiting app and focusing existing instance.') 126 | app.quit() 127 | return 128 | } 129 | 130 | finishApplicationInitialization(args) 131 | }) 132 | } 133 | 134 | async function setupDeepLinking(app: Electron.App) { 135 | if (!app.isDefaultProtocolClient(deepLinkScheme)) { 136 | app.setAsDefaultProtocolClient(deepLinkScheme) 137 | } 138 | } 139 | 140 | async function finishApplicationInitialization({ app, shell, state }: { app: App; shell: Shell; state: AppState }) { 141 | const keychainWindow = await Keychain.ensureKeychainAccess(state.store) 142 | 143 | initializeStrings(app.getLocale()) 144 | initializeExtensionsServer(state.store) 145 | 146 | const windowState = await createWindowState({ 147 | shell, 148 | appState: state, 149 | appLocale: app.getLocale(), 150 | teardown() { 151 | state.windowState = undefined 152 | }, 153 | }) 154 | 155 | /** 156 | * Close the keychain window after the main window is created, otherwise the 157 | * app will quit automatically 158 | */ 159 | keychainWindow?.close() 160 | 161 | state.windowState = windowState 162 | 163 | if ((isWindows() || isLinux()) && state.windowState.trayManager.shouldMinimizeToTray()) { 164 | state.windowState.trayManager.createTrayIcon() 165 | } 166 | 167 | windowState.window.loadURL(state.startUrl) 168 | } 169 | 170 | function initializeExtensionsServer(store: Store) { 171 | const host = createExtensionsServer() 172 | 173 | store.set(StoreKeys.ExtServerHost, host) 174 | } 175 | -------------------------------------------------------------------------------- /app/enableExperimentalWebFeatures.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | 3 | /** 4 | * @FIXME 5 | * Due to a bug in Electron (https://github.com/electron/electron/issues/28422), 6 | * downloading a file using the File System Access API does not work, causing an exception: 7 | * "Uncaught DOMException: The request is not allowed by the user agent or the platform in the current context." 8 | * 9 | * The following workaround fixes the issue by enabling experimental web platform features 10 | * which makes the file system access permission always be true. 11 | * 12 | * Since this workaround involves enabling experimental features, it could lead 13 | * to other issues. This should be removed as soon as the upstream bug is fixed. 14 | */ 15 | export const enableExperimentalFeaturesForFileAccessFix = () => 16 | app.commandLine.appendSwitch('enable-experimental-web-platform-features') 17 | -------------------------------------------------------------------------------- /app/grantLinuxPasswordsAccess.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Standard Notes 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
Password service access
18 |
19 |
20 |

Choose how you want Standard Notes to store your account keys

21 |

22 | Standard Notes can either use your operating system's password manager or its own local storage 23 | facility. 24 |

25 |

26 | Standard Notes currently does not have access to your system password service. 27 | If you grant it access, you must quit the app for the change to come into effect. 28 |

29 |
30 |
31 | 34 | 37 |
38 |
39 |
40 | Learn more 41 | 89 |
90 |
91 |
92 |
93 |
94 | 95 | 96 | -------------------------------------------------------------------------------- /app/icon/Icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/app/icon/Icon-256x256.png -------------------------------------------------------------------------------- /app/icon/Icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/app/icon/Icon-512x512.png -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 56 |
57 |
58 | 74 | 93 | 110 |
111 |
112 | 113 | 114 | -------------------------------------------------------------------------------- /app/index.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, shell } from 'electron' 2 | import path from 'path' 3 | import fs from 'fs-extra' 4 | import log from 'electron-log' 5 | import './@types/modules' 6 | import { initializeApplication } from './application' 7 | import { isSnap } from './javascripts/Main/Types/Constants' 8 | import { isTesting } from './javascripts/Main/Utils/Utils' 9 | import { setupTesting } from './javascripts/Main/Utils/Testing' 10 | import { CommandLineArgs } from './javascripts/Shared/CommandLineArgs' 11 | import { Store, StoreKeys } from './javascripts/Main/Store' 12 | import { Paths } from './javascripts/Main/Types/Paths' 13 | import { enableExperimentalFeaturesForFileAccessFix } from './enableExperimentalWebFeatures' 14 | 15 | enableExperimentalFeaturesForFileAccessFix() 16 | 17 | require('@electron/remote/main').initialize() 18 | 19 | /** Allow a custom userData path to be used. */ 20 | const userDataPathIndex = process.argv.indexOf(CommandLineArgs.UserDataPath) 21 | 22 | if (userDataPathIndex > 0) { 23 | let userDataPath = process.argv[userDataPathIndex + 1] 24 | if (typeof userDataPath === 'string') { 25 | userDataPath = path.resolve(userDataPath) 26 | 27 | /** Make sure the path is actually a writeable folder */ 28 | try { 29 | fs.closeSync(fs.openSync(path.join(userDataPath, 'sn-test-file'), 'w')) 30 | } catch (e) { 31 | console.error('Failed to write to provided user data path. Aborting') 32 | app.exit(1) 33 | } 34 | 35 | app.setPath('userData', userDataPath) 36 | } 37 | } else if (isSnap) { 38 | migrateSnapStorage() 39 | } 40 | 41 | if (isTesting()) { 42 | setupTesting() 43 | } 44 | 45 | log.transports.file.level = 'info' 46 | 47 | process.on('uncaughtException', (err) => { 48 | console.error('Uncaught exception', err) 49 | }) 50 | 51 | initializeApplication({ 52 | app, 53 | shell, 54 | ipcMain, 55 | }) 56 | 57 | /** 58 | * By default, app.get('userData') points to the snap's current revision 59 | * folder. This causes issues when updating the snap while the app is 60 | * running, because it will not copy over anything that is saved to 61 | * localStorage or IndexedDB after the installation has completed. 62 | * 63 | * To counteract this, we change the userData directory to be the snap's 64 | * 'common' directory, shared by all revisions. 65 | * We also migrate existing content in the the default user folder to the 66 | * common directory. 67 | */ 68 | function migrateSnapStorage() { 69 | const snapUserCommonDir = process.env['SNAP_USER_COMMON'] 70 | if (!snapUserCommonDir) return 71 | 72 | const legacyUserDataPath = app.getPath('userData') 73 | app.setPath('userData', snapUserCommonDir) 74 | console.log(`Set user data dir to ${snapUserCommonDir}`) 75 | 76 | const legacyFiles = fs 77 | .readdirSync(legacyUserDataPath) 78 | .filter( 79 | (fileName) => 80 | fileName !== 'SS' && 81 | fileName !== 'SingletonLock' && 82 | fileName !== 'SingletonCookie' && 83 | fileName !== 'Dictionaries' && 84 | fileName !== 'Standard Notes', 85 | ) 86 | 87 | if (legacyFiles.length) { 88 | for (const fileName of legacyFiles) { 89 | const fullFilePath = path.join(legacyUserDataPath, fileName) 90 | const dest = snapUserCommonDir 91 | console.log(`Migration: moving ${fullFilePath} to ${dest}`) 92 | try { 93 | fs.moveSync(fullFilePath, path.join(dest, fileName), { 94 | overwrite: false, 95 | }) 96 | } catch (error: any) { 97 | console.error(`Migration: error occured while moving ${fullFilePath} to ${dest}:`, error?.message ?? error) 98 | } 99 | } 100 | 101 | console.log(`Migration: finished moving contents to ${snapUserCommonDir}.`) 102 | 103 | const snapUserData = process.env['SNAP_USER_DATA'] 104 | const store = new Store(snapUserCommonDir) 105 | if (snapUserData && store.data.backupsLocation.startsWith(path.resolve(snapUserData, '..'))) { 106 | /** 107 | * Backups location has not been altered by the user. Move it to the 108 | * user documents directory 109 | */ 110 | console.log(`Migration: moving ${store.data.backupsLocation} to ${Paths.documentsDir}`) 111 | const newLocation = path.join(Paths.documentsDir, path.basename(store.data.backupsLocation)) 112 | try { 113 | fs.copySync(store.data.backupsLocation, newLocation) 114 | } catch (error: any) { 115 | console.error( 116 | `Migration: error occured while moving ${store.data.backupsLocation} to ${Paths.documentsDir}:`, 117 | error?.message ?? error, 118 | ) 119 | } 120 | store.set(StoreKeys.BackupsLocation, newLocation) 121 | console.log('Migration: finished moving backups directory.') 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /app/javascripts/Main/Backups/BackupsManager.ts: -------------------------------------------------------------------------------- 1 | import { dialog, WebContents, shell } from 'electron' 2 | import { promises as fs } from 'fs' 3 | import path from 'path' 4 | import { AppMessageType, MessageType } from '../../../../test/TestIpcMessage' 5 | import { AppState } from '../../../application' 6 | import { MessageToWebApp } from '../../Shared/IpcMessages' 7 | import { BackupsManagerInterface } from './BackupsManagerInterface' 8 | import { 9 | deleteDir, 10 | deleteDirContents, 11 | ensureDirectoryExists, 12 | FileDoesNotExist, 13 | moveFiles, 14 | openDirectoryPicker, 15 | } from '../Utils/FileUtils' 16 | import { Paths } from '../Types/Paths' 17 | import { StoreKeys } from '../Store' 18 | import { backups as str } from '../Strings' 19 | import { handleTestMessage, send } from '../Utils/Testing' 20 | import { isTesting, last } from '../Utils/Utils' 21 | 22 | function log(...message: any) { 23 | console.log('BackupsManager:', ...message) 24 | } 25 | 26 | function logError(...message: any) { 27 | console.error('BackupsManager:', ...message) 28 | } 29 | 30 | export const enum EnsureRecentBackupExists { 31 | Success = 0, 32 | BackupsAreDisabled = 1, 33 | FailedToCreateBackup = 2, 34 | } 35 | 36 | export const BackupsDirectoryName = 'Standard Notes Backups' 37 | const BackupFileExtension = '.txt' 38 | 39 | function backupFileNameToDate(string: string): number { 40 | string = path.basename(string, '.txt') 41 | const dateTimeDelimiter = string.indexOf('T') 42 | const date = string.slice(0, dateTimeDelimiter) 43 | 44 | const time = string.slice(dateTimeDelimiter + 1).replace(/-/g, ':') 45 | return Date.parse(date + 'T' + time) 46 | } 47 | 48 | function dateToSafeFilename(date: Date) { 49 | return date.toISOString().replace(/:/g, '-') 50 | } 51 | 52 | async function copyDecryptScript(location: string) { 53 | try { 54 | await ensureDirectoryExists(location) 55 | await fs.copyFile(Paths.decryptScript, path.join(location, path.basename(Paths.decryptScript))) 56 | } catch (error) { 57 | console.error(error) 58 | } 59 | } 60 | 61 | export function createBackupsManager(webContents: WebContents, appState: AppState): BackupsManagerInterface { 62 | let backupsLocation = appState.store.get(StoreKeys.BackupsLocation) 63 | let backupsDisabled = appState.store.get(StoreKeys.BackupsDisabled) 64 | let needsBackup = false 65 | 66 | if (!backupsDisabled) { 67 | copyDecryptScript(backupsLocation) 68 | } 69 | 70 | determineLastBackupDate(backupsLocation) 71 | .then((date) => appState.setBackupCreationDate(date)) 72 | .catch(console.error) 73 | 74 | async function setBackupsLocation(location: string) { 75 | const previousLocation = backupsLocation 76 | if (previousLocation === location) { 77 | return 78 | } 79 | 80 | const newLocation = path.join(location, BackupsDirectoryName) 81 | let previousLocationFiles = await fs.readdir(previousLocation) 82 | const backupFiles = previousLocationFiles 83 | .filter((fileName) => fileName.endsWith(BackupFileExtension)) 84 | .map((fileName) => path.join(previousLocation, fileName)) 85 | 86 | await moveFiles(backupFiles, newLocation) 87 | await copyDecryptScript(newLocation) 88 | 89 | previousLocationFiles = await fs.readdir(previousLocation) 90 | if (previousLocationFiles.length === 0 || previousLocationFiles[0] === path.basename(Paths.decryptScript)) { 91 | await deleteDir(previousLocation) 92 | } 93 | 94 | /** Wait for the operation to be successful before saving new location */ 95 | backupsLocation = newLocation 96 | appState.store.set(StoreKeys.BackupsLocation, backupsLocation) 97 | } 98 | 99 | async function saveBackupData(data: any) { 100 | if (backupsDisabled) { 101 | return 102 | } 103 | 104 | let success: boolean 105 | let name: string | undefined 106 | 107 | try { 108 | name = await writeDataToFile(data) 109 | log(`Data backup successfully saved: ${name}`) 110 | success = true 111 | appState.setBackupCreationDate(Date.now()) 112 | } catch (err) { 113 | success = false 114 | logError('An error occurred saving backup file', err) 115 | } 116 | 117 | webContents.send(MessageToWebApp.FinishedSavingBackup, { success }) 118 | 119 | if (isTesting()) { 120 | send(AppMessageType.SavedBackup) 121 | } 122 | 123 | return name 124 | } 125 | 126 | function performBackup() { 127 | if (backupsDisabled) { 128 | return 129 | } 130 | 131 | webContents.send(MessageToWebApp.PerformAutomatedBackup) 132 | } 133 | 134 | async function writeDataToFile(data: any): Promise { 135 | await ensureDirectoryExists(backupsLocation) 136 | 137 | const name = dateToSafeFilename(new Date()) + BackupFileExtension 138 | const filePath = path.join(backupsLocation, name) 139 | await fs.writeFile(filePath, data) 140 | return name 141 | } 142 | 143 | let interval: NodeJS.Timeout | undefined 144 | function beginBackups() { 145 | if (interval) { 146 | clearInterval(interval) 147 | } 148 | 149 | needsBackup = true 150 | const hoursInterval = 12 151 | const seconds = hoursInterval * 60 * 60 152 | const milliseconds = seconds * 1000 153 | interval = setInterval(performBackup, milliseconds) 154 | } 155 | 156 | function toggleBackupsStatus() { 157 | backupsDisabled = !backupsDisabled 158 | appState.store.set(StoreKeys.BackupsDisabled, backupsDisabled) 159 | /** Create a backup on reactivation. */ 160 | if (!backupsDisabled) { 161 | performBackup() 162 | } 163 | } 164 | 165 | if (isTesting()) { 166 | handleTestMessage(MessageType.DataArchive, (data: any) => saveBackupData(data)) 167 | handleTestMessage(MessageType.BackupsAreEnabled, () => !backupsDisabled) 168 | handleTestMessage(MessageType.ToggleBackupsEnabled, toggleBackupsStatus) 169 | handleTestMessage(MessageType.BackupsLocation, () => backupsLocation) 170 | handleTestMessage(MessageType.PerformBackup, performBackup) 171 | handleTestMessage(MessageType.CopyDecryptScript, copyDecryptScript) 172 | handleTestMessage(MessageType.ChangeBackupsLocation, setBackupsLocation) 173 | } 174 | 175 | return { 176 | get backupsAreEnabled() { 177 | return !backupsDisabled 178 | }, 179 | get backupsLocation() { 180 | return backupsLocation 181 | }, 182 | saveBackupData, 183 | performBackup, 184 | beginBackups, 185 | toggleBackupsStatus, 186 | async backupsCount(): Promise { 187 | let files = await fs.readdir(backupsLocation) 188 | files = files.filter((fileName) => fileName.endsWith(BackupFileExtension)) 189 | return files.length 190 | }, 191 | applicationDidBlur() { 192 | if (needsBackup) { 193 | needsBackup = false 194 | performBackup() 195 | } 196 | }, 197 | viewBackups() { 198 | shell.openPath(backupsLocation) 199 | }, 200 | async deleteBackups() { 201 | await deleteDirContents(backupsLocation) 202 | return copyDecryptScript(backupsLocation) 203 | }, 204 | 205 | async changeBackupsLocation() { 206 | const path = await openDirectoryPicker() 207 | 208 | if (!path) { 209 | return 210 | } 211 | 212 | try { 213 | await setBackupsLocation(path) 214 | performBackup() 215 | } catch (e) { 216 | logError(e) 217 | dialog.showMessageBox({ 218 | message: str().errorChangingDirectory(e), 219 | }) 220 | } 221 | }, 222 | } 223 | } 224 | 225 | async function determineLastBackupDate(backupsLocation: string): Promise { 226 | try { 227 | const files = (await fs.readdir(backupsLocation)) 228 | .filter((filename) => filename.endsWith(BackupFileExtension) && !Number.isNaN(backupFileNameToDate(filename))) 229 | .sort() 230 | const lastBackupFileName = last(files) 231 | if (!lastBackupFileName) { 232 | return null 233 | } 234 | const backupDate = backupFileNameToDate(lastBackupFileName) 235 | if (Number.isNaN(backupDate)) { 236 | return null 237 | } 238 | return backupDate 239 | } catch (error: any) { 240 | if (error.code !== FileDoesNotExist) { 241 | console.error(error) 242 | } 243 | return null 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /app/javascripts/Main/Backups/BackupsManagerInterface.ts: -------------------------------------------------------------------------------- 1 | export interface BackupsManagerInterface { 2 | backupsAreEnabled: boolean 3 | toggleBackupsStatus(): void 4 | backupsLocation: string 5 | backupsCount(): Promise 6 | applicationDidBlur(): void 7 | changeBackupsLocation(): void 8 | beginBackups(): void 9 | performBackup(): void 10 | deleteBackups(): Promise 11 | viewBackups(): void 12 | saveBackupData(data: unknown): void 13 | } 14 | -------------------------------------------------------------------------------- /app/javascripts/Main/ExtensionsServer.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import http, { IncomingMessage, ServerResponse } from 'http' 3 | import mime from 'mime-types' 4 | import path from 'path' 5 | import { URL } from 'url' 6 | import { FileDoesNotExist } from './Utils/FileUtils' 7 | import { Paths } from './Types/Paths' 8 | import { extensions as str } from './Strings' 9 | 10 | const Protocol = 'http' 11 | 12 | function logError(...message: any) { 13 | console.error('extServer:', ...message) 14 | } 15 | 16 | function log(...message: any) { 17 | console.log('extServer:', ...message) 18 | } 19 | 20 | export function normalizeFilePath(requestUrl: string, host: string): string { 21 | const isThirdPartyComponent = requestUrl.startsWith('/Extensions') 22 | const isNativeComponent = requestUrl.startsWith('/components') 23 | if (!isThirdPartyComponent && !isNativeComponent) { 24 | throw new Error(`URL '${requestUrl}' falls outside of the extensions/features domain.`) 25 | } 26 | 27 | const removedPrefix = requestUrl.replace('/components', '').replace('/Extensions', '') 28 | 29 | const base = `${Protocol}://${host}` 30 | const url = new URL(removedPrefix, base) 31 | 32 | /** 33 | * Normalize path (parse '..' and '.') so that we prevent path traversal by 34 | * joining a fully resolved path to the Extensions dir. 35 | */ 36 | const modifiedReqUrl = path.normalize(url.pathname) 37 | if (isThirdPartyComponent) { 38 | return path.join(Paths.extensionsDir, modifiedReqUrl) 39 | } else { 40 | return path.join(Paths.components, modifiedReqUrl) 41 | } 42 | } 43 | 44 | async function handleRequest(request: IncomingMessage, response: ServerResponse) { 45 | try { 46 | if (!request.url) throw new Error('No url.') 47 | if (!request.headers.host) throw new Error('No `host` header.') 48 | 49 | const filePath = normalizeFilePath(request.url, request.headers.host) 50 | 51 | const stat = await fs.promises.lstat(filePath) 52 | 53 | if (!stat.isFile()) { 54 | throw new Error('Client requested something that is not a file.') 55 | } 56 | 57 | const mimeType = mime.lookup(path.parse(filePath).ext) 58 | 59 | response.setHeader('Access-Control-Allow-Origin', '*') 60 | response.setHeader('Cache-Control', 'max-age=604800') 61 | response.setHeader('Content-Type', `${mimeType}; charset=utf-8`) 62 | 63 | const data = fs.readFileSync(filePath) 64 | 65 | response.writeHead(200) 66 | 67 | response.end(data) 68 | } catch (error: any) { 69 | onRequestError(error, response) 70 | } 71 | } 72 | 73 | function onRequestError(error: Error | { code: string }, response: ServerResponse) { 74 | let responseCode: number 75 | let message: string 76 | 77 | if ('code' in error && error.code === FileDoesNotExist) { 78 | responseCode = 404 79 | message = str().missingExtension 80 | } else { 81 | logError(error) 82 | responseCode = 500 83 | message = str().unableToLoadExtension 84 | } 85 | 86 | response.writeHead(responseCode) 87 | response.end(message) 88 | } 89 | 90 | export function createExtensionsServer(): string { 91 | const port = 45653 92 | const ip = '127.0.0.1' 93 | const host = `${Protocol}://${ip}:${port}` 94 | 95 | const initCallback = () => { 96 | log(`Server started at ${host}`) 97 | } 98 | 99 | try { 100 | http 101 | .createServer(handleRequest) 102 | .listen(port, ip, initCallback) 103 | .on('error', (err) => { 104 | console.error('Error listening on extServer', err) 105 | }) 106 | } catch (error) { 107 | console.error('Error creating ext server', error) 108 | } 109 | 110 | return host 111 | } 112 | -------------------------------------------------------------------------------- /app/javascripts/Main/FileBackups/FileBackupsManager.ts: -------------------------------------------------------------------------------- 1 | import { FileBackupsDevice, FileBackupsMapping } from '@web/Application/Device/DesktopSnjsExports' 2 | import { AppState } from 'app/application' 3 | import { StoreKeys } from '../Store' 4 | import { 5 | ensureDirectoryExists, 6 | moveDirContents, 7 | openDirectoryPicker, 8 | readJSONFile, 9 | writeFile, 10 | writeJSONFile, 11 | } from '../Utils/FileUtils' 12 | import { FileDownloader } from './FileDownloader' 13 | import { shell } from 'electron' 14 | 15 | export const FileBackupsConstantsV1 = { 16 | Version: '1.0.0', 17 | MetadataFileName: 'metadata.sn.json', 18 | BinaryFileName: 'file.encrypted', 19 | } 20 | 21 | export class FilesBackupManager implements FileBackupsDevice { 22 | constructor(private appState: AppState) {} 23 | 24 | public isFilesBackupsEnabled(): Promise { 25 | return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsEnabled)) 26 | } 27 | 28 | public async enableFilesBackups(): Promise { 29 | const currentLocation = await this.getFilesBackupsLocation() 30 | 31 | if (!currentLocation) { 32 | const result = await this.changeFilesBackupsLocation() 33 | 34 | if (!result) { 35 | return 36 | } 37 | } 38 | 39 | this.appState.store.set(StoreKeys.FileBackupsEnabled, true) 40 | 41 | const mapping = this.getMappingFileFromDisk() 42 | 43 | if (!mapping) { 44 | await this.saveFilesBackupsMappingFile(this.defaultMappingFileValue()) 45 | } 46 | } 47 | 48 | public disableFilesBackups(): Promise { 49 | this.appState.store.set(StoreKeys.FileBackupsEnabled, false) 50 | 51 | return Promise.resolve() 52 | } 53 | 54 | public async changeFilesBackupsLocation(): Promise { 55 | const newPath = await openDirectoryPicker() 56 | 57 | if (!newPath) { 58 | return undefined 59 | } 60 | 61 | const oldPath = await this.getFilesBackupsLocation() 62 | 63 | if (oldPath) { 64 | await moveDirContents(oldPath, newPath) 65 | } 66 | 67 | this.appState.store.set(StoreKeys.FileBackupsLocation, newPath) 68 | 69 | return newPath 70 | } 71 | 72 | public getFilesBackupsLocation(): Promise { 73 | return Promise.resolve(this.appState.store.get(StoreKeys.FileBackupsLocation)) 74 | } 75 | 76 | private getMappingFileLocation(): string { 77 | const base = this.appState.store.get(StoreKeys.FileBackupsLocation) 78 | return `${base}/info.json` 79 | } 80 | 81 | private async getMappingFileFromDisk(): Promise { 82 | return readJSONFile(this.getMappingFileLocation()) 83 | } 84 | 85 | private defaultMappingFileValue(): FileBackupsMapping { 86 | return { version: FileBackupsConstantsV1.Version, files: {} } 87 | } 88 | 89 | async getFilesBackupsMappingFile(): Promise { 90 | const data = await this.getMappingFileFromDisk() 91 | 92 | if (!data) { 93 | return this.defaultMappingFileValue() 94 | } 95 | 96 | return data 97 | } 98 | 99 | async openFilesBackupsLocation(): Promise { 100 | const location = await this.getFilesBackupsLocation() 101 | 102 | shell.openPath(location) 103 | } 104 | 105 | async saveFilesBackupsMappingFile(file: FileBackupsMapping): Promise<'success' | 'failed'> { 106 | await writeJSONFile(this.getMappingFileLocation(), file) 107 | 108 | return 'success' 109 | } 110 | 111 | async saveFilesBackupsFile( 112 | uuid: string, 113 | metaFile: string, 114 | downloadRequest: { 115 | chunkSizes: number[] 116 | valetToken: string 117 | url: string 118 | }, 119 | ): Promise<'success' | 'failed'> { 120 | const backupsDir = await this.getFilesBackupsLocation() 121 | 122 | const fileDir = `${backupsDir}/${uuid}` 123 | const metaFilePath = `${fileDir}/${FileBackupsConstantsV1.MetadataFileName}` 124 | const binaryPath = `${fileDir}/${FileBackupsConstantsV1.BinaryFileName}` 125 | 126 | await ensureDirectoryExists(fileDir) 127 | 128 | await writeFile(metaFilePath, metaFile) 129 | 130 | const downloader = new FileDownloader( 131 | downloadRequest.chunkSizes, 132 | downloadRequest.valetToken, 133 | downloadRequest.url, 134 | binaryPath, 135 | ) 136 | 137 | const result = await downloader.run() 138 | 139 | if (result === 'success') { 140 | const mapping = await this.getFilesBackupsMappingFile() 141 | 142 | mapping.files[uuid] = { 143 | backedUpOn: new Date(), 144 | absolutePath: fileDir, 145 | relativePath: uuid, 146 | metadataFileName: FileBackupsConstantsV1.MetadataFileName, 147 | binaryFileName: FileBackupsConstantsV1.BinaryFileName, 148 | version: FileBackupsConstantsV1.Version, 149 | } 150 | 151 | await this.saveFilesBackupsMappingFile(mapping) 152 | } 153 | 154 | return result 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /app/javascripts/Main/FileBackups/FileDownloader.ts: -------------------------------------------------------------------------------- 1 | import { WriteStream, createWriteStream } from 'fs' 2 | import { downloadData } from './FileNetworking' 3 | 4 | export class FileDownloader { 5 | writeStream: WriteStream 6 | 7 | constructor(private chunkSizes: number[], private valetToken: string, private url: string, filePath: string) { 8 | this.writeStream = createWriteStream(filePath, { flags: 'a' }) 9 | } 10 | 11 | public async run(): Promise<'success' | 'failed'> { 12 | const result = await this.downloadChunk(0, 0) 13 | 14 | this.writeStream.close() 15 | 16 | return result 17 | } 18 | 19 | private async downloadChunk(chunkIndex = 0, contentRangeStart: number): Promise<'success' | 'failed'> { 20 | const pullChunkSize = this.chunkSizes[chunkIndex] 21 | 22 | const headers = { 23 | 'x-valet-token': this.valetToken, 24 | 'x-chunk-size': pullChunkSize.toString(), 25 | range: `bytes=${contentRangeStart}-`, 26 | } 27 | 28 | const response = await downloadData(this.writeStream, this.url, headers) 29 | 30 | if (!String(response.status).startsWith('2')) { 31 | return 'failed' 32 | } 33 | 34 | const contentRangeHeader = response.headers['content-range'] as string 35 | if (!contentRangeHeader) { 36 | return 'failed' 37 | } 38 | 39 | const matches = contentRangeHeader.match(/(^[a-zA-Z][\w]*)\s+(\d+)\s?-\s?(\d+)?\s?\/?\s?(\d+|\*)?/) 40 | if (!matches || matches.length !== 5) { 41 | return 'failed' 42 | } 43 | 44 | const rangeStart = +matches[2] 45 | const rangeEnd = +matches[3] 46 | const totalSize = +matches[4] 47 | 48 | if (rangeEnd < totalSize - 1) { 49 | return this.downloadChunk(++chunkIndex, rangeStart + pullChunkSize) 50 | } 51 | 52 | return 'success' 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/javascripts/Main/FileBackups/FileNetworking.ts: -------------------------------------------------------------------------------- 1 | import { WriteStream } from 'fs' 2 | import axios, { AxiosResponseHeaders, AxiosRequestHeaders } from 'axios' 3 | 4 | export async function downloadData( 5 | writeStream: WriteStream, 6 | url: string, 7 | headers: AxiosRequestHeaders, 8 | ): Promise<{ 9 | headers: AxiosResponseHeaders 10 | status: number 11 | }> { 12 | const response = await axios.get(url, { 13 | responseType: 'arraybuffer', 14 | headers: headers, 15 | }) 16 | 17 | if (String(response.status).startsWith('2')) { 18 | writeStream.write(response.data) 19 | } 20 | 21 | return { headers: response.headers, status: response.status } 22 | } 23 | -------------------------------------------------------------------------------- /app/javascripts/Main/Keychain/Keychain.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain } from 'electron' 2 | import keytar from 'keytar' 3 | import { isLinux } from '../Types/Platforms' 4 | import { AppName } from '../Strings' 5 | import { keychainAccessIsUserConfigurable } from '../Types/Constants' 6 | import { isDev, isTesting } from '../Utils/Utils' 7 | import { MessageToMainProcess } from '../../Shared/IpcMessages' 8 | import { Urls, Paths } from '../Types/Paths' 9 | import { Store, StoreKeys } from '../Store' 10 | import { KeychainInterface } from './KeychainInterface' 11 | 12 | const ServiceName = isTesting() ? AppName + ' (Testing)' : isDev() ? AppName + ' (Development)' : AppName 13 | const AccountName = 'Standard Notes Account' 14 | 15 | async function ensureKeychainAccess(store: Store): Promise { 16 | if (!isLinux()) { 17 | /** Assume keychain is accessible */ 18 | return 19 | } 20 | const useNativeKeychain = store.get(StoreKeys.UseNativeKeychain) 21 | if (useNativeKeychain === null) { 22 | /** 23 | * App has never attempted to access keychain before. Do it and set the 24 | * store value according to what happens 25 | */ 26 | try { 27 | await getKeychainValue() 28 | store.set(StoreKeys.UseNativeKeychain, true) 29 | } catch (_) { 30 | /** Can't access keychain. */ 31 | if (keychainAccessIsUserConfigurable) { 32 | return askForKeychainAccess(store) 33 | } else { 34 | /** User can't configure keychain access, fall back to local storage */ 35 | store.set(StoreKeys.UseNativeKeychain, false) 36 | } 37 | } 38 | } 39 | } 40 | 41 | function askForKeychainAccess(store: Store): Promise { 42 | const window = new BrowserWindow({ 43 | width: 540, 44 | height: 400, 45 | center: true, 46 | show: false, 47 | webPreferences: { 48 | preload: Paths.grantLinuxPasswordsAccessJs, 49 | nodeIntegration: false, 50 | contextIsolation: true, 51 | }, 52 | autoHideMenuBar: true, 53 | }) 54 | window.on('ready-to-show', window.show) 55 | window.loadURL(Urls.grantLinuxPasswordsAccessHtml) 56 | 57 | const quit = () => { 58 | app.quit() 59 | } 60 | ipcMain.once(MessageToMainProcess.Quit, quit) 61 | window.once('close', quit) 62 | 63 | ipcMain.on(MessageToMainProcess.LearnMoreAboutKeychainAccess, () => { 64 | window.setSize(window.getSize()[0], 600, true) 65 | }) 66 | 67 | return new Promise((resolve) => { 68 | ipcMain.once(MessageToMainProcess.UseLocalstorageForKeychain, () => { 69 | store.set(StoreKeys.UseNativeKeychain, false) 70 | ipcMain.removeListener(MessageToMainProcess.Quit, quit) 71 | window.removeListener('close', quit) 72 | resolve(window) 73 | }) 74 | }) 75 | } 76 | 77 | async function getKeychainValue(): Promise { 78 | try { 79 | const value = await keytar.getPassword(ServiceName, AccountName) 80 | if (value) { 81 | return JSON.parse(value) 82 | } 83 | } catch (error) { 84 | console.error(error) 85 | throw error 86 | } 87 | } 88 | 89 | function setKeychainValue(value: unknown): Promise { 90 | return keytar.setPassword(ServiceName, AccountName, JSON.stringify(value)) 91 | } 92 | 93 | function clearKeychainValue(): Promise { 94 | return keytar.deletePassword(ServiceName, AccountName) 95 | } 96 | 97 | export const Keychain: KeychainInterface = { 98 | ensureKeychainAccess, 99 | getKeychainValue, 100 | setKeychainValue, 101 | clearKeychainValue, 102 | } 103 | -------------------------------------------------------------------------------- /app/javascripts/Main/Keychain/KeychainInterface.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { Store } from '../Store' 3 | 4 | export interface KeychainInterface { 5 | ensureKeychainAccess(store: Store): Promise 6 | getKeychainValue(): Promise 7 | setKeychainValue(value: unknown): Promise 8 | clearKeychainValue(): Promise 9 | } 10 | -------------------------------------------------------------------------------- /app/javascripts/Main/Menus/MenuManagerInterface.ts: -------------------------------------------------------------------------------- 1 | export interface MenuManagerInterface { 2 | reload(): void 3 | popupMenu(): void 4 | } 5 | -------------------------------------------------------------------------------- /app/javascripts/Main/Packages/Networking.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, net } from 'electron' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { pipeline as pipelineFn } from 'stream' 5 | import { promisify } from 'util' 6 | import { MessageType } from '../../../../test/TestIpcMessage' 7 | import { ensureDirectoryExists } from '../Utils/FileUtils' 8 | import { handleTestMessage } from '../Utils/Testing' 9 | import { isTesting } from '../Utils/Utils' 10 | 11 | const pipeline = promisify(pipelineFn) 12 | 13 | if (isTesting()) { 14 | handleTestMessage(MessageType.GetJSON, getJSON) 15 | handleTestMessage(MessageType.DownloadFile, downloadFile) 16 | } 17 | 18 | /** 19 | * Downloads a file to the specified destination. 20 | * @param filePath path to the saved file (will be created if it does 21 | * not exist) 22 | */ 23 | export async function downloadFile(url: string, filePath: string): Promise { 24 | await ensureDirectoryExists(path.dirname(filePath)) 25 | const response = await get(url) 26 | await pipeline( 27 | /** 28 | * IncomingMessage doesn't implement *every* property of ReadableStream 29 | * but still all the ones that pipeline needs 30 | * @see https://www.electronjs.org/docs/api/incoming-message 31 | */ 32 | response as any, 33 | fs.createWriteStream(filePath), 34 | ) 35 | } 36 | 37 | export async function getJSON(url: string): Promise { 38 | const response = await get(url) 39 | let data = '' 40 | return new Promise((resolve, reject) => { 41 | response 42 | .on('data', (chunk) => { 43 | data += chunk 44 | }) 45 | .on('error', reject) 46 | .on('end', () => { 47 | try { 48 | const parsed = JSON.parse(data) 49 | resolve(parsed) 50 | } catch (error) { 51 | resolve(undefined) 52 | } 53 | }) 54 | }) 55 | } 56 | 57 | export function get(url: string): Promise { 58 | const enum Method { 59 | Get = 'GET', 60 | } 61 | const enum RedirectMode { 62 | Follow = 'follow', 63 | } 64 | 65 | return new Promise((resolve, reject) => { 66 | const request = net.request({ 67 | url, 68 | method: Method.Get, 69 | redirect: RedirectMode.Follow, 70 | }) 71 | request.on('response', resolve) 72 | request.on('error', reject) 73 | request.end() 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /app/javascripts/Main/Packages/PackageManagerInterface.ts: -------------------------------------------------------------------------------- 1 | export interface PackageManagerInterface { 2 | syncComponents(components: Component[]): Promise 3 | } 4 | 5 | export interface Component { 6 | uuid: string 7 | deleted: boolean 8 | content?: { 9 | name?: string 10 | autoupdateDisabled: boolean 11 | local_url?: string 12 | package_info: PackageInfo 13 | } 14 | } 15 | 16 | export type PackageInfo = { 17 | identifier: string 18 | version: string 19 | download_url: string 20 | latest_url: string 21 | url: string 22 | } 23 | 24 | export interface SyncTask { 25 | components: Component[] 26 | } 27 | 28 | export interface MappingFile { 29 | [key: string]: Readonly | undefined 30 | } 31 | 32 | export interface ComponentMapping { 33 | location: string 34 | version?: string 35 | } 36 | -------------------------------------------------------------------------------- /app/javascripts/Main/Remote/DataInterface.ts: -------------------------------------------------------------------------------- 1 | export interface RemoteDataInterface { 2 | destroySensitiveDirectories(): void 3 | } 4 | -------------------------------------------------------------------------------- /app/javascripts/Main/Remote/RemoteBridge.ts: -------------------------------------------------------------------------------- 1 | import { CrossProcessBridge } from '../../Renderer/CrossProcessBridge' 2 | import { Store, StoreKeys } from '../Store' 3 | 4 | const path = require('path') 5 | const rendererPath = path.join('file://', __dirname, '/renderer.js') 6 | 7 | import { app, BrowserWindow } from 'electron' 8 | import { KeychainInterface } from '../Keychain/KeychainInterface' 9 | import { BackupsManagerInterface } from '../Backups/BackupsManagerInterface' 10 | import { PackageManagerInterface, Component } from '../Packages/PackageManagerInterface' 11 | import { SearchManagerInterface } from '../Search/SearchManagerInterface' 12 | import { RemoteDataInterface } from './DataInterface' 13 | import { MenuManagerInterface } from '../Menus/MenuManagerInterface' 14 | import { FileBackupsDevice, FileBackupsMapping } from '@web/Application/Device/DesktopSnjsExports' 15 | 16 | /** 17 | * Read https://github.com/electron/remote to understand how electron/remote works. 18 | * RemoteBridge is imported from the Preload process but is declared and created on the main process. 19 | */ 20 | export class RemoteBridge implements CrossProcessBridge { 21 | constructor( 22 | private window: BrowserWindow, 23 | private keychain: KeychainInterface, 24 | private backups: BackupsManagerInterface, 25 | private packages: PackageManagerInterface, 26 | private search: SearchManagerInterface, 27 | private data: RemoteDataInterface, 28 | private menus: MenuManagerInterface, 29 | private fileBackups: FileBackupsDevice, 30 | ) {} 31 | 32 | get exposableValue(): CrossProcessBridge { 33 | return { 34 | extServerHost: this.extServerHost, 35 | useNativeKeychain: this.useNativeKeychain, 36 | isMacOS: this.isMacOS, 37 | appVersion: this.appVersion, 38 | useSystemMenuBar: this.useSystemMenuBar, 39 | rendererPath: this.rendererPath, 40 | closeWindow: this.closeWindow.bind(this), 41 | minimizeWindow: this.minimizeWindow.bind(this), 42 | maximizeWindow: this.maximizeWindow.bind(this), 43 | unmaximizeWindow: this.unmaximizeWindow.bind(this), 44 | isWindowMaximized: this.isWindowMaximized.bind(this), 45 | getKeychainValue: this.getKeychainValue.bind(this), 46 | setKeychainValue: this.setKeychainValue.bind(this), 47 | clearKeychainValue: this.clearKeychainValue.bind(this), 48 | localBackupsCount: this.localBackupsCount.bind(this), 49 | viewlocalBackups: this.viewlocalBackups.bind(this), 50 | deleteLocalBackups: this.deleteLocalBackups.bind(this), 51 | displayAppMenu: this.displayAppMenu.bind(this), 52 | saveDataBackup: this.saveDataBackup.bind(this), 53 | syncComponents: this.syncComponents.bind(this), 54 | onMajorDataChange: this.onMajorDataChange.bind(this), 55 | onSearch: this.onSearch.bind(this), 56 | onInitialDataLoad: this.onInitialDataLoad.bind(this), 57 | destroyAllData: this.destroyAllData.bind(this), 58 | getFilesBackupsMappingFile: this.getFilesBackupsMappingFile.bind(this), 59 | saveFilesBackupsFile: this.saveFilesBackupsFile.bind(this), 60 | isFilesBackupsEnabled: this.isFilesBackupsEnabled.bind(this), 61 | enableFilesBackups: this.enableFilesBackups.bind(this), 62 | disableFilesBackups: this.disableFilesBackups.bind(this), 63 | changeFilesBackupsLocation: this.changeFilesBackupsLocation.bind(this), 64 | getFilesBackupsLocation: this.getFilesBackupsLocation.bind(this), 65 | openFilesBackupsLocation: this.openFilesBackupsLocation.bind(this), 66 | } 67 | } 68 | 69 | get extServerHost() { 70 | return Store.get(StoreKeys.ExtServerHost) 71 | } 72 | 73 | get useNativeKeychain() { 74 | return Store.get(StoreKeys.UseNativeKeychain) ?? true 75 | } 76 | 77 | get rendererPath() { 78 | return rendererPath 79 | } 80 | 81 | get isMacOS() { 82 | return process.platform === 'darwin' 83 | } 84 | 85 | get appVersion() { 86 | return app.getVersion() 87 | } 88 | 89 | get useSystemMenuBar() { 90 | return Store.get(StoreKeys.UseSystemMenuBar) 91 | } 92 | 93 | closeWindow() { 94 | this.window.close() 95 | } 96 | 97 | minimizeWindow() { 98 | this.window.minimize() 99 | } 100 | 101 | maximizeWindow() { 102 | this.window.maximize() 103 | } 104 | 105 | unmaximizeWindow() { 106 | this.window.unmaximize() 107 | } 108 | 109 | isWindowMaximized() { 110 | return this.window.isMaximized() 111 | } 112 | 113 | async getKeychainValue() { 114 | return this.keychain.getKeychainValue() 115 | } 116 | 117 | async setKeychainValue(value: unknown) { 118 | return this.keychain.setKeychainValue(value) 119 | } 120 | 121 | async clearKeychainValue() { 122 | return this.keychain.clearKeychainValue() 123 | } 124 | 125 | async localBackupsCount() { 126 | return this.backups.backupsCount() 127 | } 128 | 129 | viewlocalBackups() { 130 | this.backups.viewBackups() 131 | } 132 | 133 | async deleteLocalBackups() { 134 | return this.backups.deleteBackups() 135 | } 136 | 137 | syncComponents(components: Component[]) { 138 | this.packages.syncComponents(components) 139 | } 140 | 141 | onMajorDataChange() { 142 | this.backups.performBackup() 143 | } 144 | 145 | onSearch(text: string) { 146 | this.search.findInPage(text) 147 | } 148 | 149 | onInitialDataLoad() { 150 | this.backups.beginBackups() 151 | } 152 | 153 | destroyAllData() { 154 | this.data.destroySensitiveDirectories() 155 | } 156 | 157 | saveDataBackup(data: unknown) { 158 | this.backups.saveBackupData(data) 159 | } 160 | 161 | displayAppMenu() { 162 | this.menus.popupMenu() 163 | } 164 | 165 | getFilesBackupsMappingFile(): Promise { 166 | return this.fileBackups.getFilesBackupsMappingFile() 167 | } 168 | 169 | saveFilesBackupsFile( 170 | uuid: string, 171 | metaFile: string, 172 | downloadRequest: { 173 | chunkSizes: number[] 174 | valetToken: string 175 | url: string 176 | }, 177 | ): Promise<'success' | 'failed'> { 178 | return this.fileBackups.saveFilesBackupsFile(uuid, metaFile, downloadRequest) 179 | } 180 | 181 | public isFilesBackupsEnabled(): Promise { 182 | return this.fileBackups.isFilesBackupsEnabled() 183 | } 184 | 185 | public enableFilesBackups(): Promise { 186 | return this.fileBackups.enableFilesBackups() 187 | } 188 | 189 | public disableFilesBackups(): Promise { 190 | return this.fileBackups.disableFilesBackups() 191 | } 192 | 193 | public changeFilesBackupsLocation(): Promise { 194 | return this.fileBackups.changeFilesBackupsLocation() 195 | } 196 | 197 | public getFilesBackupsLocation(): Promise { 198 | return this.fileBackups.getFilesBackupsLocation() 199 | } 200 | 201 | public openFilesBackupsLocation(): Promise { 202 | return this.fileBackups.openFilesBackupsLocation() 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /app/javascripts/Main/Search/SearchManager.ts: -------------------------------------------------------------------------------- 1 | import { WebContents } from 'electron' 2 | import { SearchManagerInterface } from './SearchManagerInterface' 3 | 4 | export function initializeSearchManager(webContents: WebContents): SearchManagerInterface { 5 | return { 6 | findInPage(text: string) { 7 | webContents.stopFindInPage('clearSelection') 8 | if (text && text.length > 0) { 9 | // This option arrangement is required to avoid an issue where clicking on a 10 | // different note causes scroll to jump. 11 | webContents.findInPage(text) 12 | } 13 | }, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/javascripts/Main/Search/SearchManagerInterface.ts: -------------------------------------------------------------------------------- 1 | export interface SearchManagerInterface { 2 | findInPage(text: string): void 3 | } 4 | -------------------------------------------------------------------------------- /app/javascripts/Main/SpellcheckerManager.ts: -------------------------------------------------------------------------------- 1 | import { isMac } from './Types/Platforms' 2 | import { Store, StoreKeys } from './Store' 3 | import { isDev } from './Utils/Utils' 4 | 5 | export enum Language { 6 | AF = 'af', 7 | ID = 'id', 8 | CA = 'ca', 9 | CS = 'cs', 10 | CY = 'cy', 11 | DA = 'da', 12 | DE = 'de', 13 | SH = 'sh', 14 | ET = 'et', 15 | EN_AU = 'en-AU', 16 | EN_CA = 'en-CA', 17 | EN_GB = 'en-GB', 18 | EN_US = 'en-US', 19 | ES = 'es', 20 | ES_419 = 'es-419', 21 | ES_ES = 'es-ES', 22 | ES_US = 'es-US', 23 | ES_MX = 'es-MX', 24 | ES_AR = 'es-AR', 25 | FO = 'fo', 26 | FR = 'fr', 27 | HR = 'hr', 28 | IT = 'it', 29 | PL = 'pl', 30 | LV = 'lv', 31 | LT = 'lt', 32 | HU = 'hu', 33 | NL = 'nl', 34 | NB = 'nb', 35 | PT_BR = 'pt-BR', 36 | PT_PT = 'pt-PT', 37 | RO = 'ro', 38 | SQ = 'sq', 39 | SK = 'sk', 40 | SL = 'sl', 41 | SV = 'sv', 42 | VI = 'vi', 43 | TR = 'tr', 44 | EL = 'el', 45 | BG = 'bg', 46 | RU = 'ru', 47 | SR = 'sr', 48 | TG = 'tg', 49 | UK = 'uk', 50 | HY = 'hy', 51 | HE = 'he', 52 | FA = 'fa', 53 | HI = 'hi', 54 | TA = 'ta', 55 | KO = 'ko', 56 | } 57 | 58 | function isLanguage(language: any): language is Language { 59 | return Object.values(Language).includes(language) 60 | } 61 | 62 | function log(...message: any) { 63 | console.log('spellcheckerMaager:', ...message) 64 | } 65 | 66 | export interface SpellcheckerManager { 67 | languages(): Array<{ 68 | code: string 69 | name: string 70 | enabled: boolean 71 | }> 72 | addLanguage(code: string): void 73 | removeLanguage(code: string): void 74 | } 75 | 76 | export function createSpellcheckerManager( 77 | store: Store, 78 | webContents: Electron.WebContents, 79 | userLocale: string, 80 | ): SpellcheckerManager | undefined { 81 | /** 82 | * On MacOS the system spellchecker is used and every related Electron method 83 | * is a no-op. Return early to prevent unnecessary code execution/allocations 84 | */ 85 | if (isMac()) return 86 | 87 | const session = webContents.session 88 | 89 | /** 90 | * Mapping of language codes predominantly based on 91 | * https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes 92 | */ 93 | const LanguageCodes: Readonly> = { 94 | af: 'Afrikaans' /** Afrikaans */, 95 | id: 'Bahasa Indonesia' /** Indonesian */, 96 | ca: 'Català, Valencià' /** Catalan, Valencian */, 97 | cs: 'Čeština, Český Jazyk' /** Czech */, 98 | cy: 'Cymraeg' /** Welsh */, 99 | da: 'Dansk' /** Danish */, 100 | de: 'Deutsch' /** German */, 101 | sh: 'Deutsch, Schaffhausen' /** German, Canton of Schaffhausen */, 102 | et: 'Eesti, Eesti Keel' /** Estonian */, 103 | 'en-AU': 'English, Australia', 104 | 'en-CA': 'English, Canada', 105 | 'en-GB': 'English, Great Britain', 106 | 'en-US': 'English, United States', 107 | es: 'Español' /** Spanish, Castilian */, 108 | 'es-419': 'Español, America Latina' /** Spanish, Latin American */, 109 | 'es-ES': 'Español, España' /** Spanish, Spain */, 110 | 'es-US': 'Español, Estados Unidos de América' /** Spanish, United States */, 111 | 'es-MX': 'Español, Estados Unidos Mexicanos' /** Spanish, Mexico */, 112 | 'es-AR': 'Español, República Argentina' /** Spanish, Argentine Republic */, 113 | fo: 'Føroyskt' /** Faroese */, 114 | fr: 'Français' /** French */, 115 | hr: 'Hrvatski Jezik' /** Croatian */, 116 | it: 'Italiano' /** Italian */, 117 | pl: 'Język Polski, Polszczyzna' /** Polish */, 118 | lv: 'Latviešu Valoda' /** Latvian */, 119 | lt: 'Lietuvių Kalba' /** Lithuanian */, 120 | hu: 'Magyar' /** Hungarian */, 121 | nl: 'Nederlands, Vlaams' /** Dutch, Flemish */, 122 | nb: 'Norsk Bokmål' /** Norwegian Bokmål */, 123 | 'pt-BR': 'Português, Brasil' /** Portuguese, Brazil */, 124 | 'pt-PT': 'Português, República Portuguesa' /** Portuguese, Portugal */, 125 | ro: 'Română' /** Romanian, Moldavian, Moldovan */, 126 | sq: 'Shqip' /** Albanian */, 127 | sk: 'Slovenčina, Slovenský Jazyk' /** Slovak */, 128 | sl: 'Slovenski Jezik, Slovenščina' /** Slovenian */, 129 | sv: 'Svenska' /** Swedish */, 130 | vi: 'Tiếng Việt' /** Vietnamese */, 131 | tr: 'Türkçe' /** Turkish */, 132 | el: 'ελληνικά' /** Greek */, 133 | bg: 'български език' /** Bulgarian */, 134 | ru: 'Русский' /** Russian */, 135 | sr: 'српски језик' /** Serbian */, 136 | tg: 'тоҷикӣ, toçikī, تاجیکی‎' /** Tajik */, 137 | uk: 'Українська' /** Ukrainian */, 138 | hy: 'Հայերեն' /** Armenian */, 139 | he: 'עברית' /** Hebrew */, 140 | fa: 'فارسی' /** Persian */, 141 | hi: 'हिन्दी, हिंदी' /** Hindi */, 142 | ta: 'தமிழ்' /** Tamil */, 143 | ko: '한국어' /** Korean */, 144 | } 145 | 146 | const availableSpellCheckerLanguages = Object.values(Language).filter((language) => 147 | session.availableSpellCheckerLanguages.includes(language), 148 | ) 149 | 150 | if (isDev() && availableSpellCheckerLanguages.length !== session.availableSpellCheckerLanguages.length) { 151 | /** This means that not every available language has been accounted for. */ 152 | const firstOutlier = session.availableSpellCheckerLanguages.find( 153 | (language, index) => availableSpellCheckerLanguages[index] !== language, 154 | ) 155 | throw new Error(`Found unsupported language code: ${firstOutlier}`) 156 | } 157 | 158 | setSpellcheckerLanguages() 159 | 160 | function setSpellcheckerLanguages() { 161 | const { session } = webContents 162 | let selectedCodes = store.get(StoreKeys.SelectedSpellCheckerLanguageCodes) 163 | 164 | if (selectedCodes === null) { 165 | /** First-time setup. Set a default language */ 166 | selectedCodes = determineDefaultSpellcheckerLanguageCodes(session.availableSpellCheckerLanguages, userLocale) 167 | store.set(StoreKeys.SelectedSpellCheckerLanguageCodes, selectedCodes) 168 | } 169 | session.setSpellCheckerLanguages([...selectedCodes]) 170 | } 171 | 172 | function determineDefaultSpellcheckerLanguageCodes( 173 | availableSpellCheckerLanguages: string[], 174 | userLocale: string, 175 | ): Set { 176 | const localeIsSupported = availableSpellCheckerLanguages.includes(userLocale) 177 | if (localeIsSupported && isLanguage(userLocale)) { 178 | return new Set([userLocale]) 179 | } else { 180 | log(`Spellchecker doesn't support locale '${userLocale}'.`) 181 | return new Set() 182 | } 183 | } 184 | 185 | function selectedLanguageCodes(): Set { 186 | return store.get(StoreKeys.SelectedSpellCheckerLanguageCodes) || new Set() 187 | } 188 | 189 | return { 190 | languages() { 191 | const codes = selectedLanguageCodes() 192 | return availableSpellCheckerLanguages.map((code) => ({ 193 | code, 194 | name: LanguageCodes[code], 195 | enabled: codes.has(code), 196 | })) 197 | }, 198 | addLanguage(code: Language) { 199 | const selectedCodes = selectedLanguageCodes() 200 | selectedCodes.add(code) 201 | store.set(StoreKeys.SelectedSpellCheckerLanguageCodes, selectedCodes) 202 | session.setSpellCheckerLanguages(Array.from(selectedCodes)) 203 | }, 204 | removeLanguage(code: Language) { 205 | const selectedCodes = selectedLanguageCodes() 206 | selectedCodes.delete(code) 207 | store.set(StoreKeys.SelectedSpellCheckerLanguageCodes, selectedCodes) 208 | session.setSpellCheckerLanguages(Array.from(selectedCodes)) 209 | }, 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /app/javascripts/Main/Store.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { MessageType } from '../../../test/TestIpcMessage' 4 | import { Language } from './SpellcheckerManager' 5 | import { ensureIsBoolean, isTesting, isDev, isBoolean } from './Utils/Utils' 6 | import { FileDoesNotExist } from './Utils/FileUtils' 7 | import { BackupsDirectoryName } from './Backups/BackupsManager' 8 | import { handleTestMessage } from './Utils/Testing' 9 | 10 | const app = process.type === 'browser' ? require('electron').app : require('@electron/remote').app 11 | 12 | function logError(...message: any) { 13 | console.error('store:', ...message) 14 | } 15 | 16 | export enum StoreKeys { 17 | ExtServerHost = 'extServerHost', 18 | UseSystemMenuBar = 'useSystemMenuBar', 19 | MenuBarVisible = 'isMenuBarVisible', 20 | BackupsLocation = 'backupsLocation', 21 | BackupsDisabled = 'backupsDisabled', 22 | MinimizeToTray = 'minimizeToTray', 23 | EnableAutoUpdate = 'enableAutoUpdates', 24 | ZoomFactor = 'zoomFactor', 25 | SelectedSpellCheckerLanguageCodes = 'selectedSpellCheckerLanguageCodes', 26 | UseNativeKeychain = 'useNativeKeychain', 27 | 28 | FileBackupsEnabled = 'fileBackupsEnabled', 29 | FileBackupsLocation = 'fileBackupsLocation', 30 | } 31 | 32 | interface StoreData { 33 | [StoreKeys.ExtServerHost]: string 34 | [StoreKeys.UseSystemMenuBar]: boolean 35 | [StoreKeys.MenuBarVisible]: boolean 36 | [StoreKeys.BackupsLocation]: string 37 | [StoreKeys.BackupsDisabled]: boolean 38 | [StoreKeys.MinimizeToTray]: boolean 39 | [StoreKeys.EnableAutoUpdate]: boolean 40 | [StoreKeys.UseNativeKeychain]: boolean | null 41 | [StoreKeys.ZoomFactor]: number 42 | [StoreKeys.SelectedSpellCheckerLanguageCodes]: Set | null 43 | [StoreKeys.FileBackupsEnabled]: boolean 44 | [StoreKeys.FileBackupsLocation]: string 45 | } 46 | 47 | function createSanitizedStoreData(data: any = {}): StoreData { 48 | return { 49 | [StoreKeys.MenuBarVisible]: ensureIsBoolean(data[StoreKeys.MenuBarVisible], true), 50 | [StoreKeys.UseSystemMenuBar]: ensureIsBoolean(data[StoreKeys.UseSystemMenuBar], false), 51 | [StoreKeys.BackupsDisabled]: ensureIsBoolean(data[StoreKeys.BackupsDisabled], false), 52 | [StoreKeys.MinimizeToTray]: ensureIsBoolean(data[StoreKeys.MinimizeToTray], false), 53 | [StoreKeys.EnableAutoUpdate]: ensureIsBoolean(data[StoreKeys.EnableAutoUpdate], true), 54 | [StoreKeys.UseNativeKeychain]: isBoolean(data[StoreKeys.UseNativeKeychain]) 55 | ? data[StoreKeys.UseNativeKeychain] 56 | : null, 57 | [StoreKeys.ExtServerHost]: data[StoreKeys.ExtServerHost], 58 | [StoreKeys.BackupsLocation]: sanitizeBackupsLocation(data[StoreKeys.BackupsLocation]), 59 | [StoreKeys.ZoomFactor]: sanitizeZoomFactor(data[StoreKeys.ZoomFactor]), 60 | [StoreKeys.SelectedSpellCheckerLanguageCodes]: sanitizeSpellCheckerLanguageCodes( 61 | data[StoreKeys.SelectedSpellCheckerLanguageCodes], 62 | ), 63 | [StoreKeys.FileBackupsEnabled]: ensureIsBoolean(data[StoreKeys.FileBackupsEnabled], false), 64 | [StoreKeys.FileBackupsLocation]: data[StoreKeys.FileBackupsLocation], 65 | } 66 | } 67 | 68 | function sanitizeZoomFactor(factor?: any): number { 69 | if (typeof factor === 'number' && factor > 0) { 70 | return factor 71 | } else { 72 | return 1 73 | } 74 | } 75 | 76 | function sanitizeBackupsLocation(location?: unknown): string { 77 | const defaultPath = path.join( 78 | isTesting() ? app.getPath('userData') : isDev() ? app.getPath('documents') : app.getPath('home'), 79 | BackupsDirectoryName, 80 | ) 81 | 82 | if (typeof location !== 'string') { 83 | return defaultPath 84 | } 85 | 86 | try { 87 | const stat = fs.lstatSync(location) 88 | if (stat.isDirectory()) { 89 | return location 90 | } 91 | /** Path points to something other than a directory */ 92 | return defaultPath 93 | } catch (e) { 94 | /** Path does not point to a valid directory */ 95 | logError(e) 96 | return defaultPath 97 | } 98 | } 99 | 100 | function sanitizeSpellCheckerLanguageCodes(languages?: unknown): Set | null { 101 | if (!languages) return null 102 | if (!Array.isArray(languages)) return null 103 | 104 | const set = new Set() 105 | const validLanguages = Object.values(Language) 106 | for (const language of languages) { 107 | if (validLanguages.includes(language)) { 108 | set.add(language) 109 | } 110 | } 111 | return set 112 | } 113 | 114 | export function serializeStoreData(data: StoreData): string { 115 | return JSON.stringify(data, (_key, value) => { 116 | if (value instanceof Set) { 117 | return Array.from(value) 118 | } 119 | return value 120 | }) 121 | } 122 | 123 | function parseDataFile(filePath: string) { 124 | try { 125 | const fileData = fs.readFileSync(filePath) 126 | const userData = JSON.parse(fileData.toString()) 127 | return createSanitizedStoreData(userData) 128 | } catch (error: any) { 129 | console.log('Error reading store file', error) 130 | if (error.code !== FileDoesNotExist) { 131 | logError(error) 132 | } 133 | 134 | return createSanitizedStoreData({}) 135 | } 136 | } 137 | 138 | export class Store { 139 | static instance: Store 140 | readonly path: string 141 | readonly data: StoreData 142 | 143 | static getInstance(): Store { 144 | if (!this.instance) { 145 | /** 146 | * Renderer process has to get `app` module via `remote`, whereas the main process 147 | * can get it directly app.getPath('userData') will return a string of the user's 148 | * app data directory path. 149 | * TODO(baptiste): stop using Store in the renderer process. 150 | */ 151 | const userDataPath = app.getPath('userData') 152 | this.instance = new Store(userDataPath) 153 | } 154 | return this.instance 155 | } 156 | 157 | static get(key: T): StoreData[T] { 158 | return this.getInstance().get(key) 159 | } 160 | 161 | constructor(userDataPath: string) { 162 | this.path = path.join(userDataPath, 'user-preferences.json') 163 | this.data = parseDataFile(this.path) 164 | 165 | if (isTesting()) { 166 | handleTestMessage(MessageType.StoreSettingsLocation, () => this.path) 167 | handleTestMessage(MessageType.StoreSet, (key, value) => { 168 | this.set(key, value) 169 | }) 170 | } 171 | } 172 | 173 | get(key: T): StoreData[T] { 174 | return this.data[key] 175 | } 176 | 177 | set(key: T, val: StoreData[T]): void { 178 | this.data[key] = val 179 | fs.writeFileSync(this.path, serializeStoreData(this.data)) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /app/javascripts/Main/Strings/english.ts: -------------------------------------------------------------------------------- 1 | import { Strings } from './types' 2 | 3 | export function createEnglishStrings(): Strings { 4 | return { 5 | appMenu: { 6 | edit: 'Edit', 7 | view: 'View', 8 | hideMenuBar: 'Hide Menu Bar', 9 | useThemedMenuBar: 'Use Themed Menu Bar', 10 | minimizeToTrayOnClose: 'Minimize To Tray On Close', 11 | backups: 'Backups', 12 | enableAutomaticUpdates: 'Enable Automatic Updates', 13 | automaticUpdatesDisabled: 'Automatic Updates Disabled', 14 | disableAutomaticBackups: 'Disable Automatic Backups', 15 | enableAutomaticBackups: 'Enable Automatic Backups', 16 | changeBackupsLocation: 'Change Backups Location', 17 | openBackupsLocation: 'Open Backups Location', 18 | emailSupport: 'Email Support', 19 | website: 'Website', 20 | gitHub: 'GitHub', 21 | slack: 'Slack', 22 | twitter: 'Twitter', 23 | toggleErrorConsole: 'Toggle Error Console', 24 | openDataDirectory: 'Open Data Directory', 25 | clearCacheAndReload: 'Clear Cache and Reload', 26 | speech: 'Speech', 27 | close: 'Close', 28 | minimize: 'Minimize', 29 | zoom: 'Zoom', 30 | bringAllToFront: 'Bring All to Front', 31 | checkForUpdate: 'Check for Update', 32 | checkingForUpdate: 'Checking for update…', 33 | updateAvailable: '(1) Update Available', 34 | updates: 'Updates', 35 | releaseNotes: 'Release Notes', 36 | openDownloadLocation: 'Open Download Location', 37 | downloadingUpdate: 'Downloading Update…', 38 | manuallyDownloadUpdate: 'Manually Download Update', 39 | spellcheckerLanguages: 'Spellchecker Languages', 40 | installPendingUpdate(versionNumber: string) { 41 | return `Install Pending Update (${versionNumber})` 42 | }, 43 | lastUpdateCheck(date: Date) { 44 | return `Last checked ${date.toLocaleString()}` 45 | }, 46 | version(number: string) { 47 | return `Version: ${number}` 48 | }, 49 | yourVersion(number: string) { 50 | return `Your Version: ${number}` 51 | }, 52 | latestVersion(number: string) { 53 | return `Latest Version: ${number}` 54 | }, 55 | viewReleaseNotes(versionNumber: string) { 56 | return `View ${versionNumber} Release Notes` 57 | }, 58 | preferencesChanged: { 59 | title: 'Preference Changed', 60 | message: 61 | 'Your menu bar preference has been saved. Please restart the ' + 'application for the change to take effect.', 62 | }, 63 | security: { 64 | security: 'Security', 65 | useKeyringtoStorePassword: 'Use password storage to store password', 66 | enabledKeyringAccessMessage: 67 | "Standard Notes will try to use your system's password storage " + 68 | 'facility to store your password the next time you start it.', 69 | enabledKeyringQuitNow: 'Quit Now', 70 | enabledKeyringPostpone: 'Postpone', 71 | }, 72 | }, 73 | contextMenu: { 74 | learnSpelling: 'Learn Spelling', 75 | noSuggestions: 'No Suggestions', 76 | }, 77 | tray: { 78 | show: 'Show', 79 | hide: 'Hide', 80 | quit: 'Quit', 81 | }, 82 | extensions: { 83 | missingExtension: 84 | 'The extension was not found on your system, possibly because it is ' + 85 | "still downloading. If the extension doesn't load, " + 86 | 'try uninstalling then reinstalling the extension.', 87 | unableToLoadExtension: 88 | 'Unable to load extension. Please restart the application and ' + 89 | 'try again. If the issue persists, try uninstalling then ' + 90 | 'reinstalling the extension.', 91 | }, 92 | updates: { 93 | automaticUpdatesEnabled: { 94 | title: 'Automatic Updates Enabled.', 95 | message: 96 | 'Automatic updates have been enabled. Please note that ' + 97 | 'this functionality is currently in beta, and that you are advised ' + 98 | 'to periodically check in and ensure you are running the ' + 99 | 'latest version.', 100 | }, 101 | finishedChecking: { 102 | title: 'Finished checking for updates.', 103 | error(description: string) { 104 | return ( 105 | 'An issue occurred while checking for updates. ' + 106 | 'Please try again.\nIf this issue persists please contact ' + 107 | `support with the following information: ${description}` 108 | ) 109 | }, 110 | updateAvailable(newVersion: string) { 111 | return ( 112 | `A new update is available (version ${newVersion}). ` + 113 | 'You can wait for the app to update itself, or manually ' + 114 | 'download and install this update.' 115 | ) 116 | }, 117 | noUpdateAvailable(currentVersion: string) { 118 | return `Your version (${currentVersion}) is the latest available version.` 119 | }, 120 | }, 121 | updateReady: { 122 | title: 'Update Ready', 123 | message(version: string) { 124 | return `A new update (version ${version}) is ready to install.` 125 | }, 126 | quitAndInstall: 'Quit and Install', 127 | installLater: 'Install Later', 128 | noRecentBackupMessage: 129 | 'An update is ready to install, but your backups folder does not ' + 130 | 'appear to contain a recent enough backup. Please download a ' + 131 | 'backup manually before proceeding with the installation.', 132 | noRecentBackupDetail(lastBackupDate: number | null) { 133 | const downloadInstructions = 134 | 'You can download a backup from the Account menu ' + 'in the bottom-left corner of the app.' 135 | const lastAutomaticBackup = 136 | lastBackupDate === null 137 | ? 'Your backups folder is empty.' 138 | : `Your latest automatic backup is from ${new Date(lastBackupDate).toLocaleString()}.` 139 | return `${downloadInstructions}\n${lastAutomaticBackup}` 140 | }, 141 | noRecentBackupChecbox: 'I have downloaded a backup, proceed with installation', 142 | }, 143 | errorDownloading: { 144 | title: 'Error Downloading', 145 | message: 'An error occurred while trying to download your ' + 'update file. Please try again.', 146 | }, 147 | unknownVersionName: 'Unknown', 148 | }, 149 | backups: { 150 | errorChangingDirectory(error: any): string { 151 | return ( 152 | 'An error occurred while changing your backups directory. ' + 153 | 'If this issue persists, please contact support with the following ' + 154 | 'information: \n' + 155 | JSON.stringify(error) 156 | ) 157 | }, 158 | }, 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /app/javascripts/Main/Strings/french.ts: -------------------------------------------------------------------------------- 1 | import { Strings } from './types' 2 | import { createEnglishStrings } from './english' 3 | import { isDev } from '../Utils/Utils' 4 | 5 | export function createFrenchStrings(): Strings { 6 | const fallback = createEnglishStrings() 7 | if (!isDev()) { 8 | /** 9 | * Le Français est une langue expérimentale. 10 | * Don't show it in production yet. 11 | */ 12 | return fallback 13 | } 14 | return { 15 | appMenu: { 16 | ...fallback.appMenu, 17 | edit: 'Édition', 18 | view: 'Affichage', 19 | }, 20 | contextMenu: { 21 | learnSpelling: "Mémoriser l'orthographe", 22 | noSuggestions: 'Aucune suggestion', 23 | }, 24 | tray: { 25 | show: 'Afficher', 26 | hide: 'Masquer', 27 | quit: 'Quitter', 28 | }, 29 | extensions: fallback.extensions, 30 | updates: fallback.updates, 31 | backups: { 32 | errorChangingDirectory(error: any): string { 33 | return ( 34 | "Une erreur s'est produite lors du déplacement du dossier de " + 35 | 'sauvegardes. Si le problème est récurrent, contactez le support ' + 36 | 'technique (en anglais) avec les informations suivantes:\n' + 37 | JSON.stringify(error) 38 | ) 39 | }, 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/javascripts/Main/Strings/index.ts: -------------------------------------------------------------------------------- 1 | import { createEnglishStrings } from './english' 2 | import { createFrenchStrings } from './french' 3 | import { Strings } from './types' 4 | import { isDev } from '../Utils/Utils' 5 | 6 | let strings: Strings 7 | 8 | /** 9 | * MUST be called (once) before using any other export in this file. 10 | * @param locale The user's locale 11 | * @see https://www.electronjs.org/docs/api/locales 12 | */ 13 | export function initializeStrings(locale: string): void { 14 | if (isDev()) { 15 | if (strings) { 16 | throw new Error('`strings` has already been initialized') 17 | } 18 | } 19 | if (strings) return 20 | strings = stringsForLocale(locale) 21 | } 22 | 23 | export function str(): Strings { 24 | if (isDev()) { 25 | if (!strings) { 26 | throw new Error('tried to access strings before they were initialized.') 27 | } 28 | } 29 | return strings 30 | } 31 | 32 | export function appMenu() { 33 | return str().appMenu 34 | } 35 | 36 | export function contextMenu() { 37 | return str().contextMenu 38 | } 39 | 40 | export function tray() { 41 | return str().tray 42 | } 43 | 44 | export function extensions() { 45 | return str().extensions 46 | } 47 | 48 | export function updates() { 49 | return str().updates 50 | } 51 | 52 | export function backups() { 53 | return str().backups 54 | } 55 | 56 | function stringsForLocale(locale: string): Strings { 57 | if (locale === 'en' || locale.startsWith('en-')) { 58 | return createEnglishStrings() 59 | } else if (locale === 'fr' || locale.startsWith('fr-')) { 60 | return createFrenchStrings() 61 | } 62 | 63 | return createEnglishStrings() 64 | } 65 | 66 | export const AppName = 'Standard Notes' 67 | -------------------------------------------------------------------------------- /app/javascripts/Main/Strings/types.ts: -------------------------------------------------------------------------------- 1 | export interface Strings { 2 | appMenu: AppMenuStrings 3 | contextMenu: ContextMenuStrings 4 | tray: TrayStrings 5 | extensions: ExtensionsStrings 6 | updates: UpdateStrings 7 | backups: BackupsStrings 8 | } 9 | 10 | interface AppMenuStrings { 11 | edit: string 12 | view: string 13 | hideMenuBar: string 14 | useThemedMenuBar: string 15 | minimizeToTrayOnClose: string 16 | backups: string 17 | enableAutomaticUpdates: string 18 | automaticUpdatesDisabled: string 19 | disableAutomaticBackups: string 20 | enableAutomaticBackups: string 21 | changeBackupsLocation: string 22 | openBackupsLocation: string 23 | emailSupport: string 24 | website: string 25 | gitHub: string 26 | slack: string 27 | twitter: string 28 | toggleErrorConsole: string 29 | openDataDirectory: string 30 | clearCacheAndReload: string 31 | speech: string 32 | close: string 33 | minimize: string 34 | zoom: string 35 | bringAllToFront: string 36 | checkForUpdate: string 37 | checkingForUpdate: string 38 | updateAvailable: string 39 | updates: string 40 | releaseNotes: string 41 | openDownloadLocation: string 42 | downloadingUpdate: string 43 | manuallyDownloadUpdate: string 44 | spellcheckerLanguages: string 45 | installPendingUpdate(versionNumber: string): string 46 | lastUpdateCheck(date: Date): string 47 | version(number: string): string 48 | yourVersion(number: string): string 49 | latestVersion(number: string): string 50 | viewReleaseNotes(versionNumber: string): string 51 | preferencesChanged: { 52 | title: string 53 | message: string 54 | } 55 | security: { 56 | security: string 57 | useKeyringtoStorePassword: string 58 | enabledKeyringAccessMessage: string 59 | enabledKeyringQuitNow: string 60 | enabledKeyringPostpone: string 61 | } 62 | } 63 | 64 | interface ContextMenuStrings { 65 | learnSpelling: string 66 | noSuggestions: string 67 | } 68 | 69 | interface TrayStrings { 70 | show: string 71 | hide: string 72 | quit: string 73 | } 74 | 75 | interface ExtensionsStrings { 76 | unableToLoadExtension: string 77 | missingExtension: string 78 | } 79 | 80 | interface UpdateStrings { 81 | automaticUpdatesEnabled: { 82 | title: string 83 | message: string 84 | } 85 | finishedChecking: { 86 | title: string 87 | error(description: string): string 88 | updateAvailable(newVersion: string): string 89 | noUpdateAvailable(currentVersion: string): string 90 | } 91 | updateReady: { 92 | title: string 93 | message(version: string): string 94 | quitAndInstall: string 95 | installLater: string 96 | noRecentBackupMessage: string 97 | noRecentBackupDetail(lastBackupDate: number | null): string 98 | noRecentBackupChecbox: string 99 | } 100 | errorDownloading: { 101 | title: string 102 | message: string 103 | } 104 | unknownVersionName: string 105 | } 106 | 107 | interface BackupsStrings { 108 | errorChangingDirectory(error: any): string 109 | } 110 | -------------------------------------------------------------------------------- /app/javascripts/Main/TrayManager.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Tray } from 'electron' 2 | import path from 'path' 3 | import { isLinux, isWindows } from './Types/Platforms' 4 | import { Store, StoreKeys } from './Store' 5 | import { AppName, tray as str } from './Strings' 6 | import { isDev } from './Utils/Utils' 7 | 8 | const icon = path.join(__dirname, '/icon/Icon-256x256.png') 9 | 10 | export interface TrayManager { 11 | shouldMinimizeToTray(): boolean 12 | createTrayIcon(): void 13 | destroyTrayIcon(): void 14 | } 15 | 16 | export function createTrayManager(window: Electron.BrowserWindow, store: Store): TrayManager { 17 | let tray: Tray | undefined 18 | let updateContextMenu: (() => void) | undefined 19 | 20 | function showWindow() { 21 | window.show() 22 | 23 | if (isLinux()) { 24 | /* On some versions of GNOME the window may not be on top when 25 | restored. */ 26 | window.setAlwaysOnTop(true) 27 | window.focus() 28 | window.setAlwaysOnTop(false) 29 | } 30 | } 31 | 32 | return { 33 | shouldMinimizeToTray() { 34 | return store.get(StoreKeys.MinimizeToTray) 35 | }, 36 | 37 | createTrayIcon() { 38 | tray = new Tray(icon) 39 | tray.setToolTip(AppName) 40 | 41 | if (isWindows()) { 42 | /* On Windows, right-clicking invokes the menu, as opposed to 43 | left-clicking for the other platforms. So we map left-clicking 44 | to the conventional action of showing the app. */ 45 | tray.on('click', showWindow) 46 | } 47 | 48 | const SHOW_WINDOW_ID = 'SHOW_WINDOW' 49 | const HIDE_WINDOW_ID = 'HIDE_WINDOW' 50 | const trayContextMenu = Menu.buildFromTemplate([ 51 | { 52 | id: SHOW_WINDOW_ID, 53 | label: str().show, 54 | click: showWindow, 55 | }, 56 | { 57 | id: HIDE_WINDOW_ID, 58 | label: str().hide, 59 | click() { 60 | window.hide() 61 | }, 62 | }, 63 | { 64 | type: 'separator', 65 | }, 66 | { 67 | role: 'quit', 68 | label: str().quit, 69 | }, 70 | ]) 71 | 72 | updateContextMenu = function updateContextMenu() { 73 | if (window.isVisible()) { 74 | trayContextMenu.getMenuItemById(SHOW_WINDOW_ID)!.visible = false 75 | trayContextMenu.getMenuItemById(HIDE_WINDOW_ID)!.visible = true 76 | } else { 77 | trayContextMenu.getMenuItemById(SHOW_WINDOW_ID)!.visible = true 78 | trayContextMenu.getMenuItemById(HIDE_WINDOW_ID)!.visible = false 79 | } 80 | 81 | tray!.setContextMenu(trayContextMenu) 82 | } 83 | updateContextMenu() // initialization 84 | 85 | window.on('hide', updateContextMenu) 86 | window.on('focus', updateContextMenu) 87 | window.on('blur', updateContextMenu) 88 | }, 89 | 90 | destroyTrayIcon() { 91 | if (isDev()) { 92 | /** Check our state */ 93 | if (!updateContextMenu) { 94 | throw new Error('updateContextMenu === undefined') 95 | } 96 | if (!tray) throw new Error('tray === undefined') 97 | } 98 | 99 | window.off('hide', updateContextMenu!) 100 | window.off('focus', updateContextMenu!) 101 | window.off('blur', updateContextMenu!) 102 | tray!.destroy() 103 | tray = undefined 104 | updateContextMenu = undefined 105 | }, 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/javascripts/Main/Types/Constants.ts: -------------------------------------------------------------------------------- 1 | /** Build-time constants */ 2 | declare const IS_SNAP: boolean 3 | 4 | export const isSnap = IS_SNAP 5 | export const autoUpdatingAvailable = !isSnap 6 | export const keychainAccessIsUserConfigurable = isSnap 7 | -------------------------------------------------------------------------------- /app/javascripts/Main/Types/Paths.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import index from '../../../index.html' 3 | import grantLinuxPasswordsAccess from '../../../grantLinuxPasswordsAccess.html' 4 | import decryptScript from 'decrypt/dist/decrypt.html' 5 | import { app } from 'electron' 6 | 7 | function url(fileName: string): string { 8 | if ('APP_RELATIVE_PATH' in process.env) { 9 | return path.join('file://', __dirname, process.env.APP_RELATIVE_PATH as string, fileName) 10 | } 11 | return path.join('file://', __dirname, fileName) 12 | } 13 | 14 | function filePath(fileName: string): string { 15 | if ('APP_RELATIVE_PATH' in process.env) { 16 | return path.join(__dirname, process.env.APP_RELATIVE_PATH as string, fileName) 17 | } 18 | return path.join(__dirname, fileName) 19 | } 20 | 21 | export const Urls = { 22 | get indexHtml(): string { 23 | return url(index) 24 | }, 25 | get grantLinuxPasswordsAccessHtml(): string { 26 | return url(grantLinuxPasswordsAccess) 27 | }, 28 | } 29 | 30 | /** 31 | * App paths can be modified at runtime, most frequently at startup, so don't 32 | * store the results of these getters in long-lived constants (like static class 33 | * fields). 34 | */ 35 | export const Paths = { 36 | get userDataDir(): string { 37 | return app.getPath('userData') 38 | }, 39 | get documentsDir(): string { 40 | return app.getPath('documents') 41 | }, 42 | get tempDir(): string { 43 | return app.getPath('temp') 44 | }, 45 | get extensionsDirRelative(): string { 46 | return 'Extensions' 47 | }, 48 | get extensionsDir(): string { 49 | return path.join(Paths.userDataDir, 'Extensions') 50 | }, 51 | get extensionsMappingJson(): string { 52 | return path.join(Paths.extensionsDir, 'mapping.json') 53 | }, 54 | get windowPositionJson(): string { 55 | return path.join(Paths.userDataDir, 'window-position.json') 56 | }, 57 | get decryptScript(): string { 58 | return filePath(decryptScript) 59 | }, 60 | get preloadJs(): string { 61 | return path.join(__dirname, 'javascripts/renderer/preload.js') 62 | }, 63 | get components(): string { 64 | return `${app.getAppPath()}/dist/standard-notes-web/components` 65 | }, 66 | get grantLinuxPasswordsAccessJs(): string { 67 | return path.join(__dirname, 'javascripts/renderer/grantLinuxPasswordsAccess.js') 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /app/javascripts/Main/Types/Platforms.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO(baptiste): precompute these booleans at compile-time 3 | * (requires one webpack build per platform) 4 | */ 5 | 6 | export function isWindows(): boolean { 7 | return process.platform === 'win32' 8 | } 9 | export function isMac(): boolean { 10 | return process.platform === 'darwin' 11 | } 12 | export function isLinux(): boolean { 13 | return process.platform === 'linux' 14 | } 15 | 16 | export type InstallerKey = 'mac' | 'windows' | 'appimage_64' | 'appimage_32' 17 | export function getInstallerKey(): InstallerKey { 18 | if (isWindows()) { 19 | return 'windows' 20 | } else if (isMac()) { 21 | return 'mac' 22 | } else if (isLinux()) { 23 | if (process.arch === 'x32') { 24 | return 'appimage_32' 25 | } else { 26 | return 'appimage_64' 27 | } 28 | } else { 29 | throw new Error(`Unknown platform: ${process.platform}`) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/javascripts/Main/UpdateManager.ts: -------------------------------------------------------------------------------- 1 | import compareVersions from 'compare-versions' 2 | import { BrowserWindow, dialog, shell } from 'electron' 3 | import electronLog from 'electron-log' 4 | import { autoUpdater } from 'electron-updater' 5 | import { action, autorun, computed, makeObservable, observable } from 'mobx' 6 | import { autoUpdatingAvailable } from './Types/Constants' 7 | import { MessageType } from '../../../test/TestIpcMessage' 8 | import { AppState } from '../../application' 9 | import { BackupsManagerInterface } from './Backups/BackupsManagerInterface' 10 | import { StoreKeys } from './Store' 11 | import { updates as str } from './Strings' 12 | import { handleTestMessage } from './Utils/Testing' 13 | import { isTesting } from './Utils/Utils' 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | function logError(...message: any) { 17 | console.error('updateManager:', ...message) 18 | } 19 | 20 | if (isTesting()) { 21 | // eslint-disable-next-line no-var 22 | var notifiedStateUpdate = false 23 | } 24 | 25 | export class UpdateState { 26 | latestVersion: string | null = null 27 | enableAutoUpdate: boolean 28 | checkingForUpdate = false 29 | autoUpdateDownloaded = false 30 | lastCheck: Date | null = null 31 | 32 | constructor(private appState: AppState) { 33 | this.enableAutoUpdate = autoUpdatingAvailable && appState.store.get(StoreKeys.EnableAutoUpdate) 34 | makeObservable(this, { 35 | latestVersion: observable, 36 | enableAutoUpdate: observable, 37 | checkingForUpdate: observable, 38 | autoUpdateDownloaded: observable, 39 | lastCheck: observable, 40 | 41 | updateNeeded: computed, 42 | 43 | toggleAutoUpdate: action, 44 | setCheckingForUpdate: action, 45 | autoUpdateHasBeenDownloaded: action, 46 | checkedForUpdate: action, 47 | }) 48 | 49 | if (isTesting()) { 50 | handleTestMessage(MessageType.UpdateState, () => ({ 51 | lastCheck: this.lastCheck, 52 | })) 53 | } 54 | } 55 | 56 | get updateNeeded(): boolean { 57 | if (this.latestVersion) { 58 | return compareVersions(this.latestVersion, this.appState.version) === 1 59 | } else { 60 | return false 61 | } 62 | } 63 | 64 | toggleAutoUpdate(): void { 65 | this.enableAutoUpdate = !this.enableAutoUpdate 66 | this.appState.store.set(StoreKeys.EnableAutoUpdate, this.enableAutoUpdate) 67 | } 68 | 69 | setCheckingForUpdate(checking: boolean): void { 70 | this.checkingForUpdate = checking 71 | } 72 | 73 | autoUpdateHasBeenDownloaded(version: string | null): void { 74 | this.autoUpdateDownloaded = true 75 | this.latestVersion = version 76 | } 77 | 78 | checkedForUpdate(latestVersion: string | null): void { 79 | this.lastCheck = new Date() 80 | this.latestVersion = latestVersion 81 | } 82 | } 83 | 84 | let updatesSetup = false 85 | 86 | export function setupUpdates(window: BrowserWindow, appState: AppState, backupsManager: BackupsManagerInterface): void { 87 | if (!autoUpdatingAvailable) { 88 | return 89 | } 90 | if (updatesSetup) { 91 | throw Error('Already set up updates.') 92 | } 93 | const { store } = appState 94 | 95 | autoUpdater.logger = electronLog 96 | 97 | const updateState = appState.updates 98 | 99 | function checkUpdateSafety(): boolean { 100 | let canUpdate: boolean 101 | if (appState.store.get(StoreKeys.BackupsDisabled)) { 102 | canUpdate = true 103 | } else { 104 | canUpdate = updateState.enableAutoUpdate && isLessThanOneHourFromNow(appState.lastBackupDate) 105 | } 106 | autoUpdater.autoInstallOnAppQuit = canUpdate 107 | autoUpdater.autoDownload = canUpdate 108 | return canUpdate 109 | } 110 | autorun(checkUpdateSafety) 111 | 112 | const oneHour = 1 * 60 * 60 * 1000 113 | setInterval(checkUpdateSafety, oneHour) 114 | 115 | autoUpdater.on('update-downloaded', (info: { version?: string }) => { 116 | window.webContents.send('update-available', null) 117 | updateState.autoUpdateHasBeenDownloaded(info.version || null) 118 | }) 119 | 120 | autoUpdater.on('error', logError) 121 | autoUpdater.on('update-available', (info: { version?: string }) => { 122 | updateState.checkedForUpdate(info.version || null) 123 | if (updateState.enableAutoUpdate) { 124 | const canUpdate = checkUpdateSafety() 125 | if (!canUpdate) { 126 | backupsManager.performBackup() 127 | } 128 | } 129 | }) 130 | autoUpdater.on('update-not-available', (info: { version?: string }) => { 131 | updateState.checkedForUpdate(info.version || null) 132 | }) 133 | 134 | updatesSetup = true 135 | 136 | if (isTesting()) { 137 | handleTestMessage(MessageType.AutoUpdateEnabled, () => store.get(StoreKeys.EnableAutoUpdate)) 138 | handleTestMessage(MessageType.CheckForUpdate, () => checkForUpdate(appState, updateState)) 139 | handleTestMessage(MessageType.UpdateManagerNotifiedStateChange, () => notifiedStateUpdate) 140 | } else { 141 | checkForUpdate(appState, updateState) 142 | } 143 | } 144 | 145 | export function openChangelog(state: UpdateState): void { 146 | const url = 'https://github.com/standardnotes/desktop/releases' 147 | if (state.latestVersion) { 148 | shell.openExternal(`${url}/tag/v${state.latestVersion}`) 149 | } else { 150 | shell.openExternal(url) 151 | } 152 | } 153 | 154 | function quitAndInstall(window: BrowserWindow) { 155 | setTimeout(() => { 156 | // index.js prevents close event on some platforms 157 | window.removeAllListeners('close') 158 | window.close() 159 | autoUpdater.quitAndInstall(false) 160 | }, 0) 161 | } 162 | 163 | function isLessThanOneHourFromNow(date: number | null) { 164 | const now = Date.now() 165 | const onHourMs = 1 * 60 * 60 * 1000 166 | return now - (date ?? 0) < onHourMs 167 | } 168 | 169 | export async function showUpdateInstallationDialog(parentWindow: BrowserWindow, appState: AppState): Promise { 170 | if (!appState.updates.latestVersion) return 171 | if (appState.lastBackupDate && isLessThanOneHourFromNow(appState.lastBackupDate)) { 172 | const result = await dialog.showMessageBox(parentWindow, { 173 | type: 'info', 174 | title: str().updateReady.title, 175 | message: str().updateReady.message(appState.updates.latestVersion), 176 | buttons: [str().updateReady.installLater, str().updateReady.quitAndInstall], 177 | cancelId: 0, 178 | }) 179 | 180 | const buttonIndex = result.response 181 | if (buttonIndex === 1) { 182 | quitAndInstall(parentWindow) 183 | } 184 | } else { 185 | const cancelId = 0 186 | const result = await dialog.showMessageBox({ 187 | type: 'warning', 188 | title: str().updateReady.title, 189 | message: str().updateReady.noRecentBackupMessage, 190 | detail: str().updateReady.noRecentBackupDetail(appState.lastBackupDate), 191 | checkboxLabel: str().updateReady.noRecentBackupChecbox, 192 | checkboxChecked: false, 193 | buttons: [str().updateReady.installLater, str().updateReady.quitAndInstall], 194 | cancelId, 195 | }) 196 | 197 | if (!result.checkboxChecked || result.response === cancelId) { 198 | return 199 | } 200 | quitAndInstall(parentWindow) 201 | } 202 | } 203 | 204 | export async function checkForUpdate(appState: AppState, state: UpdateState, userTriggered = false): Promise { 205 | if (!autoUpdatingAvailable) return 206 | 207 | if (state.enableAutoUpdate || userTriggered) { 208 | state.setCheckingForUpdate(true) 209 | 210 | try { 211 | const result = await autoUpdater.checkForUpdates() 212 | 213 | if (!result) { 214 | return 215 | } 216 | 217 | state.checkedForUpdate(result.updateInfo.version) 218 | 219 | if (userTriggered) { 220 | let message 221 | if (state.updateNeeded && state.latestVersion) { 222 | message = str().finishedChecking.updateAvailable(state.latestVersion) 223 | } else { 224 | message = str().finishedChecking.noUpdateAvailable(appState.version) 225 | } 226 | 227 | dialog.showMessageBox({ 228 | title: str().finishedChecking.title, 229 | message, 230 | }) 231 | } 232 | } catch (error) { 233 | if (userTriggered) { 234 | dialog.showMessageBox({ 235 | title: str().finishedChecking.title, 236 | message: str().finishedChecking.error(JSON.stringify(error)), 237 | }) 238 | } 239 | } finally { 240 | state.setCheckingForUpdate(false) 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /app/javascripts/Main/Utils/FileUtils.ts: -------------------------------------------------------------------------------- 1 | import fs, { PathLike } from 'fs' 2 | import { debounce } from 'lodash' 3 | import path from 'path' 4 | import yauzl from 'yauzl' 5 | import { removeFromArray } from '../Utils/Utils' 6 | import { dialog } from 'electron' 7 | 8 | export const FileDoesNotExist = 'ENOENT' 9 | export const FileAlreadyExists = 'EEXIST' 10 | const CrossDeviceLink = 'EXDEV' 11 | const OperationNotPermitted = 'EPERM' 12 | const DeviceIsBusy = 'EBUSY' 13 | 14 | export function debouncedJSONDiskWriter(durationMs: number, location: string, data: () => unknown): () => void { 15 | let writingToDisk = false 16 | return debounce(async () => { 17 | if (writingToDisk) return 18 | writingToDisk = true 19 | try { 20 | await writeJSONFile(location, data()) 21 | } catch (error) { 22 | console.error(error) 23 | } finally { 24 | writingToDisk = false 25 | } 26 | }, durationMs) 27 | } 28 | 29 | export async function openDirectoryPicker(): Promise { 30 | const result = await dialog.showOpenDialog({ 31 | properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'], 32 | }) 33 | 34 | return result.filePaths[0] 35 | } 36 | 37 | export async function readJSONFile(filepath: string): Promise { 38 | try { 39 | const data = await fs.promises.readFile(filepath, 'utf8') 40 | return JSON.parse(data) 41 | } catch (error) { 42 | return undefined 43 | } 44 | } 45 | 46 | export function readJSONFileSync(filepath: string): T { 47 | const data = fs.readFileSync(filepath, 'utf8') 48 | return JSON.parse(data) 49 | } 50 | 51 | export async function writeJSONFile(filepath: string, data: unknown): Promise { 52 | await ensureDirectoryExists(path.dirname(filepath)) 53 | await fs.promises.writeFile(filepath, JSON.stringify(data, null, 2), 'utf8') 54 | } 55 | 56 | export async function writeFile(filepath: string, data: string): Promise { 57 | await ensureDirectoryExists(path.dirname(filepath)) 58 | await fs.promises.writeFile(filepath, data, 'utf8') 59 | } 60 | 61 | export function writeJSONFileSync(filepath: string, data: unknown): void { 62 | fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8') 63 | } 64 | 65 | export async function ensureDirectoryExists(dirPath: string): Promise { 66 | try { 67 | const stat = await fs.promises.lstat(dirPath) 68 | if (!stat.isDirectory()) { 69 | throw new Error('Tried to create a directory where a file of the same ' + `name already exists: ${dirPath}`) 70 | } 71 | } catch (error: any) { 72 | if (error.code === FileDoesNotExist) { 73 | /** 74 | * No directory here. Make sure there is a *parent* directory, and then 75 | * create it. 76 | */ 77 | await ensureDirectoryExists(path.dirname(dirPath)) 78 | 79 | /** Now that its parent(s) exist, create the directory */ 80 | try { 81 | await fs.promises.mkdir(dirPath) 82 | } catch (error: any) { 83 | if (error.code === FileAlreadyExists) { 84 | /** 85 | * A concurrent process must have created the directory already. 86 | * Make sure it *is* a directory and not something else. 87 | */ 88 | await ensureDirectoryExists(dirPath) 89 | } else { 90 | throw error 91 | } 92 | } 93 | } else { 94 | throw error 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * Deletes a directory (handling recursion.) 101 | * @param {string} dirPath the path of the directory 102 | */ 103 | export async function deleteDir(dirPath: string): Promise { 104 | try { 105 | await deleteDirContents(dirPath) 106 | } catch (error: any) { 107 | if (error.code === FileDoesNotExist) { 108 | /** Directory has already been deleted. */ 109 | return 110 | } 111 | throw error 112 | } 113 | await fs.promises.rmdir(dirPath) 114 | } 115 | 116 | export async function deleteDirContents(dirPath: string): Promise { 117 | /** 118 | * Scan the directory up to ten times, to handle cases where files are being added while 119 | * the directory's contents are being deleted 120 | */ 121 | for (let i = 1, maxTries = 10; i < maxTries; i++) { 122 | const children = await fs.promises.readdir(dirPath, { 123 | withFileTypes: true, 124 | }) 125 | if (children.length === 0) break 126 | for (const child of children) { 127 | const childPath = path.join(dirPath, child.name) 128 | if (child.isDirectory()) { 129 | await deleteDirContents(childPath) 130 | try { 131 | await fs.promises.rmdir(childPath) 132 | } catch (error) { 133 | if (error !== FileDoesNotExist) { 134 | throw error 135 | } 136 | } 137 | } else { 138 | await deleteFile(childPath) 139 | } 140 | } 141 | } 142 | } 143 | 144 | function isChildOfDir(parent: string, potentialChild: string) { 145 | const relative = path.relative(parent, potentialChild) 146 | return relative && !relative.startsWith('..') && !path.isAbsolute(relative) 147 | } 148 | 149 | export async function moveDirContents(srcDir: string, destDir: string): Promise { 150 | let fileNames = await fs.promises.readdir(srcDir) 151 | await ensureDirectoryExists(destDir) 152 | 153 | if (isChildOfDir(srcDir, destDir)) { 154 | fileNames = fileNames.filter((name) => { 155 | return !isChildOfDir(destDir, path.join(srcDir, name)) 156 | }) 157 | removeFromArray(fileNames, path.basename(destDir)) 158 | } 159 | 160 | return moveFiles( 161 | fileNames.map((fileName) => path.join(srcDir, fileName)), 162 | destDir, 163 | ) 164 | } 165 | 166 | export async function extractNestedZip(source: string, dest: string): Promise { 167 | return new Promise((resolve, reject) => { 168 | yauzl.open(source, { lazyEntries: true, autoClose: true }, (err, zipFile) => { 169 | let cancelled = false 170 | const tryReject = (err: Error) => { 171 | if (!cancelled) { 172 | cancelled = true 173 | reject(err) 174 | } 175 | } 176 | if (err) return tryReject(err) 177 | if (!zipFile) return tryReject(new Error('zipFile === undefined')) 178 | 179 | zipFile.readEntry() 180 | zipFile.on('close', resolve) 181 | zipFile.on('entry', (entry) => { 182 | if (cancelled) return 183 | if (entry.fileName.endsWith('/')) { 184 | /** entry is a directory, skip and read next entry */ 185 | zipFile.readEntry() 186 | return 187 | } 188 | 189 | zipFile.openReadStream(entry, async (err, stream) => { 190 | if (cancelled) return 191 | if (err) return tryReject(err) 192 | if (!stream) return tryReject(new Error('stream === undefined')) 193 | stream.on('error', tryReject) 194 | const filepath = path.join( 195 | dest, 196 | /** 197 | * Remove the first element of the entry's path, which is the base 198 | * directory we want to ignore 199 | */ 200 | entry.fileName.substring(entry.fileName.indexOf('/') + 1), 201 | ) 202 | try { 203 | await ensureDirectoryExists(path.dirname(filepath)) 204 | } catch (error: any) { 205 | return tryReject(error) 206 | } 207 | const writeStream = fs.createWriteStream(filepath).on('error', tryReject).on('error', tryReject) 208 | 209 | stream.pipe(writeStream).on('close', () => { 210 | zipFile.readEntry() /** Reads next entry. */ 211 | }) 212 | }) 213 | }) 214 | }) 215 | }) 216 | } 217 | 218 | export async function moveFiles(sources: string[], destDir: string): Promise { 219 | await ensureDirectoryExists(destDir) 220 | return Promise.all(sources.map((fileName) => moveFile(fileName, path.join(destDir, path.basename(fileName))))) 221 | } 222 | 223 | async function moveFile(source: PathLike, destination: PathLike) { 224 | try { 225 | await fs.promises.rename(source, destination) 226 | } catch (error: any) { 227 | if (error.code === CrossDeviceLink) { 228 | /** Fall back to copying and then deleting. */ 229 | await fs.promises.copyFile(source, destination) 230 | await fs.promises.unlink(source) 231 | } else { 232 | throw error 233 | } 234 | } 235 | } 236 | 237 | /** Deletes a file, handling EPERM and EBUSY errors on Windows. */ 238 | export async function deleteFile(filePath: PathLike): Promise { 239 | for (let i = 1, maxTries = 10; i < maxTries; i++) { 240 | try { 241 | await fs.promises.unlink(filePath) 242 | break 243 | } catch (error: any) { 244 | if (error.code === OperationNotPermitted || error.code === DeviceIsBusy) { 245 | await new Promise((resolve) => setTimeout(resolve, 300)) 246 | continue 247 | } else if (error.code === FileDoesNotExist) { 248 | /** Already deleted */ 249 | break 250 | } 251 | throw error 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /app/javascripts/Main/Utils/Testing.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | import { AppMessageType, MessageType, TestIPCMessage } from '../../../../test/TestIpcMessage' 3 | import { isTesting } from '../Utils/Utils' 4 | 5 | const messageHandlers: { 6 | [key in MessageType]?: (...args: any) => unknown 7 | } = {} 8 | 9 | export function handleTestMessage(type: MessageType, handler: (...args: any) => unknown): void { 10 | if (!isTesting()) { 11 | throw Error('Tried to invoke test handler in non-test build.') 12 | } 13 | 14 | messageHandlers[type] = handler 15 | } 16 | 17 | export function send(type: AppMessageType, data?: unknown): void { 18 | if (!isTesting()) return 19 | 20 | process.send!({ type, data }) 21 | } 22 | 23 | export function setupTesting(): void { 24 | process.on('message', async (message: TestIPCMessage) => { 25 | const handler = messageHandlers[message.type] 26 | 27 | if (!handler) { 28 | process.send!({ 29 | id: message.id, 30 | reject: `No handler registered for message type ${MessageType[message.type]}`, 31 | }) 32 | return 33 | } 34 | 35 | try { 36 | let returnValue = handler(...message.args) 37 | if (returnValue instanceof Promise) { 38 | returnValue = await returnValue 39 | } 40 | process.send!({ 41 | id: message.id, 42 | resolve: returnValue, 43 | }) 44 | } catch (error: any) { 45 | process.send!({ 46 | id: message.id, 47 | reject: error.toString(), 48 | }) 49 | } 50 | }) 51 | 52 | handleTestMessage(MessageType.WindowCount, () => BrowserWindow.getAllWindows().length) 53 | 54 | app.on('ready', () => { 55 | setTimeout(() => { 56 | send(AppMessageType.Ready) 57 | }, 200) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /app/javascripts/Main/Utils/Utils.ts: -------------------------------------------------------------------------------- 1 | import { CommandLineArgs } from '../../Shared/CommandLineArgs' 2 | 3 | export function isDev(): boolean { 4 | return process.env.NODE_ENV === 'development' 5 | } 6 | 7 | export function isTesting(): boolean { 8 | return isDev() && process.argv.includes(CommandLineArgs.Testing) 9 | } 10 | 11 | export function isBoolean(arg: unknown): arg is boolean { 12 | return typeof arg === 'boolean' 13 | } 14 | 15 | export function ensureIsBoolean(arg: unknown, fallbackValue: boolean): boolean { 16 | if (isBoolean(arg)) { 17 | return arg 18 | } 19 | return fallbackValue 20 | } 21 | 22 | export function stringOrNull(arg: unknown): string | null { 23 | if (typeof arg === 'string') { 24 | return arg 25 | } 26 | return null 27 | } 28 | 29 | /** Ensures a path's drive letter is lowercase. */ 30 | export function lowercaseDriveLetter(filePath: string): string { 31 | return filePath.replace(/^\/[A-Z]:\//, (letter) => letter.toLowerCase()) 32 | } 33 | 34 | export function timeout(ms: number): Promise { 35 | return new Promise((resolve) => setTimeout(resolve, ms)) 36 | } 37 | 38 | export function removeFromArray(array: T[], toRemove: T): void { 39 | array.splice(array.indexOf(toRemove), 1) 40 | } 41 | 42 | export function last(array: T[]): T | undefined { 43 | return array[array.length - 1] 44 | } 45 | -------------------------------------------------------------------------------- /app/javascripts/Main/ZoomManager.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { Store, StoreKeys } from './Store' 3 | 4 | export function initializeZoomManager(window: BrowserWindow, store: Store): void { 5 | window.webContents.on('dom-ready', () => { 6 | const zoomFactor = store.get(StoreKeys.ZoomFactor) 7 | if (zoomFactor) { 8 | window.webContents.zoomFactor = zoomFactor 9 | } 10 | }) 11 | 12 | window.on('close', () => { 13 | const zoomFactor = window.webContents.zoomFactor 14 | store.set(StoreKeys.ZoomFactor, zoomFactor) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /app/javascripts/Renderer/CrossProcessBridge.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../Main/Packages/PackageManagerInterface' 2 | import { FileBackupsDevice } from '@web/Application/Device/DesktopSnjsExports' 3 | 4 | export interface CrossProcessBridge extends FileBackupsDevice { 5 | get extServerHost(): string 6 | 7 | get useNativeKeychain(): boolean 8 | 9 | get rendererPath(): string 10 | 11 | get isMacOS(): boolean 12 | 13 | get appVersion(): string 14 | 15 | get useSystemMenuBar(): boolean 16 | 17 | closeWindow(): void 18 | 19 | minimizeWindow(): void 20 | 21 | maximizeWindow(): void 22 | 23 | unmaximizeWindow(): void 24 | 25 | isWindowMaximized(): boolean 26 | 27 | getKeychainValue(): Promise 28 | 29 | setKeychainValue: (value: unknown) => Promise 30 | 31 | clearKeychainValue(): Promise 32 | 33 | localBackupsCount(): Promise 34 | 35 | viewlocalBackups(): void 36 | 37 | deleteLocalBackups(): Promise 38 | 39 | saveDataBackup(data: unknown): void 40 | 41 | displayAppMenu(): void 42 | 43 | syncComponents(components: Component[]): void 44 | 45 | onMajorDataChange(): void 46 | 47 | onSearch(text: string): void 48 | 49 | onInitialDataLoad(): void 50 | 51 | destroyAllData(): void 52 | } 53 | -------------------------------------------------------------------------------- /app/javascripts/Renderer/DesktopDevice.ts: -------------------------------------------------------------------------------- 1 | import { WebOrDesktopDevice } from '@web/Application/Device/WebOrDesktopDevice' 2 | import { Component } from '../Main/Packages/PackageManagerInterface' 3 | import { 4 | RawKeychainValue, 5 | Environment, 6 | DesktopDeviceInterface, 7 | FileBackupsMapping, 8 | } from '@web/Application/Device/DesktopSnjsExports' 9 | import { CrossProcessBridge } from './CrossProcessBridge' 10 | 11 | const FallbackLocalStorageKey = 'keychain' 12 | 13 | export class DesktopDevice extends WebOrDesktopDevice implements DesktopDeviceInterface { 14 | public environment: Environment.Desktop = Environment.Desktop 15 | 16 | constructor( 17 | private remoteBridge: CrossProcessBridge, 18 | private useNativeKeychain: boolean, 19 | public extensionsServerHost: string, 20 | appVersion: string, 21 | ) { 22 | super(appVersion) 23 | } 24 | 25 | async getKeychainValue() { 26 | if (this.useNativeKeychain) { 27 | const keychainValue = await this.remoteBridge.getKeychainValue() 28 | return keychainValue 29 | } else { 30 | const value = window.localStorage.getItem(FallbackLocalStorageKey) 31 | if (value) { 32 | return JSON.parse(value) 33 | } 34 | } 35 | } 36 | 37 | async setKeychainValue(value: RawKeychainValue) { 38 | if (this.useNativeKeychain) { 39 | await this.remoteBridge.setKeychainValue(value) 40 | } else { 41 | window.localStorage.setItem(FallbackLocalStorageKey, JSON.stringify(value)) 42 | } 43 | } 44 | 45 | async clearRawKeychainValue() { 46 | if (this.useNativeKeychain) { 47 | await this.remoteBridge.clearKeychainValue() 48 | } else { 49 | window.localStorage.removeItem(FallbackLocalStorageKey) 50 | } 51 | } 52 | 53 | syncComponents(components: Component[]) { 54 | this.remoteBridge.syncComponents(components) 55 | } 56 | 57 | onMajorDataChange() { 58 | this.remoteBridge.onMajorDataChange() 59 | } 60 | 61 | onSearch(text: string) { 62 | this.remoteBridge.onSearch(text) 63 | } 64 | 65 | onInitialDataLoad() { 66 | this.remoteBridge.onInitialDataLoad() 67 | } 68 | 69 | async clearAllDataFromDevice(workspaceIdentifiers: string[]): Promise<{ killsApplication: boolean }> { 70 | await super.clearAllDataFromDevice(workspaceIdentifiers) 71 | 72 | this.remoteBridge.destroyAllData() 73 | 74 | return { killsApplication: true } 75 | } 76 | 77 | async downloadBackup() { 78 | const receiver = window.webClient 79 | 80 | receiver.didBeginBackup() 81 | 82 | try { 83 | const data = await receiver.requestBackupFile() 84 | if (data) { 85 | this.remoteBridge.saveDataBackup(data) 86 | } else { 87 | receiver.didFinishBackup(false) 88 | } 89 | } catch (error) { 90 | console.error(error) 91 | receiver.didFinishBackup(false) 92 | } 93 | } 94 | 95 | async localBackupsCount() { 96 | return this.remoteBridge.localBackupsCount() 97 | } 98 | 99 | viewlocalBackups() { 100 | this.remoteBridge.viewlocalBackups() 101 | } 102 | 103 | async deleteLocalBackups() { 104 | this.remoteBridge.deleteLocalBackups() 105 | } 106 | 107 | public isFilesBackupsEnabled(): Promise { 108 | return this.remoteBridge.isFilesBackupsEnabled() 109 | } 110 | 111 | public enableFilesBackups(): Promise { 112 | return this.remoteBridge.enableFilesBackups() 113 | } 114 | 115 | public disableFilesBackups(): Promise { 116 | return this.remoteBridge.disableFilesBackups() 117 | } 118 | 119 | public changeFilesBackupsLocation(): Promise { 120 | return this.remoteBridge.changeFilesBackupsLocation() 121 | } 122 | 123 | public getFilesBackupsLocation(): Promise { 124 | return this.remoteBridge.getFilesBackupsLocation() 125 | } 126 | 127 | async getFilesBackupsMappingFile(): Promise { 128 | return this.remoteBridge.getFilesBackupsMappingFile() 129 | } 130 | 131 | async openFilesBackupsLocation(): Promise { 132 | return this.remoteBridge.openFilesBackupsLocation() 133 | } 134 | 135 | async saveFilesBackupsFile( 136 | uuid: string, 137 | metaFile: string, 138 | downloadRequest: { 139 | chunkSizes: number[] 140 | valetToken: string 141 | url: string 142 | }, 143 | ): Promise<'success' | 'failed'> { 144 | return this.remoteBridge.saveFilesBackupsFile(uuid, metaFile, downloadRequest) 145 | } 146 | 147 | async performHardReset(): Promise { 148 | console.error('performHardReset is not yet implemented') 149 | } 150 | 151 | isDeviceDestroyed(): boolean { 152 | return false 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /app/javascripts/Renderer/Preload.ts: -------------------------------------------------------------------------------- 1 | import { MessageToWebApp } from '../Shared/IpcMessages' 2 | const { ipcRenderer } = require('electron') 3 | const path = require('path') 4 | const rendererPath = path.join('file://', __dirname, '/renderer.js') 5 | const RemoteBridge = require('@electron/remote').getGlobal('RemoteBridge') 6 | const { contextBridge } = require('electron') 7 | 8 | process.once('loaded', function () { 9 | contextBridge.exposeInMainWorld('electronRemoteBridge', RemoteBridge.exposableValue) 10 | 11 | listenForIpcEventsFromMainProcess() 12 | }) 13 | 14 | function listenForIpcEventsFromMainProcess() { 15 | const sendMessageToRenderProcess = (message: string, payload = {}) => { 16 | window.postMessage(JSON.stringify({ message, data: payload }), rendererPath) 17 | } 18 | 19 | ipcRenderer.on(MessageToWebApp.UpdateAvailable, function (_event, data) { 20 | sendMessageToRenderProcess(MessageToWebApp.UpdateAvailable, data) 21 | }) 22 | 23 | ipcRenderer.on(MessageToWebApp.PerformAutomatedBackup, function (_event, data) { 24 | sendMessageToRenderProcess(MessageToWebApp.PerformAutomatedBackup, data) 25 | }) 26 | 27 | ipcRenderer.on(MessageToWebApp.FinishedSavingBackup, function (_event, data) { 28 | sendMessageToRenderProcess(MessageToWebApp.FinishedSavingBackup, data) 29 | }) 30 | 31 | ipcRenderer.on(MessageToWebApp.WindowBlurred, function (_event, data) { 32 | sendMessageToRenderProcess(MessageToWebApp.WindowBlurred, data) 33 | }) 34 | 35 | ipcRenderer.on(MessageToWebApp.WindowFocused, function (_event, data) { 36 | sendMessageToRenderProcess(MessageToWebApp.WindowFocused, data) 37 | }) 38 | 39 | ipcRenderer.on(MessageToWebApp.InstallComponentComplete, function (_event, data) { 40 | sendMessageToRenderProcess(MessageToWebApp.InstallComponentComplete, data) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /app/javascripts/Renderer/Renderer.ts: -------------------------------------------------------------------------------- 1 | import { DesktopDevice } from './DesktopDevice' 2 | import { MessageToWebApp } from '../Shared/IpcMessages' 3 | import { DesktopClientRequiresWebMethods } from '@web/Application/Device/DesktopSnjsExports' 4 | import { StartApplication } from '@web/Application/Device/StartApplication' 5 | import { CrossProcessBridge } from './CrossProcessBridge' 6 | 7 | declare const DEFAULT_SYNC_SERVER: string 8 | declare const WEBSOCKET_URL: string 9 | declare const ENABLE_UNFINISHED_FEATURES: string 10 | declare const PURCHASE_URL: string 11 | declare const PLANS_URL: string 12 | declare const DASHBOARD_URL: string 13 | 14 | declare global { 15 | interface Window { 16 | device: DesktopDevice 17 | electronRemoteBridge: CrossProcessBridge 18 | dashboardUrl: string 19 | webClient: DesktopClientRequiresWebMethods 20 | electronAppVersion: string 21 | enableUnfinishedFeatures: boolean 22 | plansUrl: string 23 | purchaseUrl: string 24 | startApplication: StartApplication 25 | zip: any 26 | } 27 | } 28 | 29 | const loadWindowVarsRequiredByWebApp = () => { 30 | window.dashboardUrl = DASHBOARD_URL 31 | window.enableUnfinishedFeatures = ENABLE_UNFINISHED_FEATURES === 'true' 32 | window.plansUrl = PLANS_URL 33 | window.purchaseUrl = PURCHASE_URL 34 | } 35 | 36 | const loadAndStartApplication = async () => { 37 | const remoteBridge: CrossProcessBridge = window.electronRemoteBridge 38 | 39 | await configureWindow(remoteBridge) 40 | 41 | window.device = await createDesktopDevice(remoteBridge) 42 | 43 | window.startApplication(DEFAULT_SYNC_SERVER, window.device, window.enableUnfinishedFeatures, WEBSOCKET_URL) 44 | 45 | listenForMessagesSentFromMainToPreloadToUs(window.device) 46 | } 47 | 48 | window.onload = () => { 49 | loadWindowVarsRequiredByWebApp() 50 | 51 | loadAndStartApplication() 52 | 53 | loadZipLibrary() 54 | } 55 | 56 | /** @returns whether the keychain structure is up to date or not */ 57 | async function migrateKeychain(remoteBridge: CrossProcessBridge): Promise { 58 | if (!remoteBridge.useNativeKeychain) { 59 | /** User chose not to use keychain, do not migrate. */ 60 | return false 61 | } 62 | 63 | const key = 'keychain' 64 | const localStorageValue = window.localStorage.getItem(key) 65 | 66 | if (localStorageValue) { 67 | /** Migrate to native keychain */ 68 | console.warn('Migrating keychain from localStorage to native keychain.') 69 | window.localStorage.removeItem(key) 70 | await remoteBridge.setKeychainValue(JSON.parse(localStorageValue)) 71 | } 72 | 73 | return true 74 | } 75 | 76 | async function createDesktopDevice(remoteBridge: CrossProcessBridge): Promise { 77 | const useNativeKeychain = await migrateKeychain(remoteBridge) 78 | const extensionsServerHost = remoteBridge.extServerHost 79 | const appVersion = remoteBridge.appVersion 80 | 81 | return new DesktopDevice(remoteBridge, useNativeKeychain, extensionsServerHost, appVersion) 82 | } 83 | 84 | async function configureWindow(remoteBridge: CrossProcessBridge) { 85 | const isMacOS = remoteBridge.isMacOS 86 | const useSystemMenuBar = remoteBridge.useSystemMenuBar 87 | const appVersion = remoteBridge.appVersion 88 | 89 | window.electronAppVersion = appVersion 90 | 91 | /* 92 | Title bar events 93 | */ 94 | document.getElementById('menu-btn')!.addEventListener('click', () => { 95 | remoteBridge.displayAppMenu() 96 | }) 97 | 98 | document.getElementById('min-btn')!.addEventListener('click', () => { 99 | remoteBridge.minimizeWindow() 100 | }) 101 | 102 | document.getElementById('max-btn')!.addEventListener('click', async () => { 103 | if (remoteBridge.isWindowMaximized()) { 104 | remoteBridge.unmaximizeWindow() 105 | } else { 106 | remoteBridge.maximizeWindow() 107 | } 108 | }) 109 | 110 | document.getElementById('close-btn')!.addEventListener('click', () => { 111 | remoteBridge.closeWindow() 112 | }) 113 | 114 | // For Mac inset window 115 | const sheet = document.styleSheets[0] 116 | if (isMacOS) { 117 | sheet.insertRule('#navigation { padding-top: 25px !important; }', sheet.cssRules.length) 118 | } 119 | 120 | if (isMacOS || useSystemMenuBar) { 121 | // !important is important here because #desktop-title-bar has display: flex. 122 | sheet.insertRule('#desktop-title-bar { display: none !important; }', sheet.cssRules.length) 123 | } else { 124 | /* Use custom title bar. Take the sn-titlebar-height off of 125 | the app content height so its not overflowing */ 126 | sheet.insertRule('body { padding-top: var(--sn-desktop-titlebar-height); }', sheet.cssRules.length) 127 | sheet.insertRule( 128 | `.main-ui-view { height: calc(100vh - var(--sn-desktop-titlebar-height)) !important; 129 | min-height: calc(100vh - var(--sn-desktop-titlebar-height)) !important; }`, 130 | sheet.cssRules.length, 131 | ) 132 | } 133 | } 134 | 135 | function listenForMessagesSentFromMainToPreloadToUs(device: DesktopDevice) { 136 | window.addEventListener('message', async (event) => { 137 | // We don't have access to the full file path. 138 | if (event.origin !== 'file://') { 139 | return 140 | } 141 | let payload 142 | try { 143 | payload = JSON.parse(event.data) 144 | } catch (e) { 145 | // message doesn't belong to us 146 | return 147 | } 148 | const receiver = window.webClient 149 | const message = payload.message 150 | const data = payload.data 151 | 152 | if (message === MessageToWebApp.WindowBlurred) { 153 | receiver.windowLostFocus() 154 | } else if (message === MessageToWebApp.WindowFocused) { 155 | receiver.windowGainedFocus() 156 | } else if (message === MessageToWebApp.InstallComponentComplete) { 157 | receiver.onComponentInstallationComplete(data.component, data.error) 158 | } else if (message === MessageToWebApp.UpdateAvailable) { 159 | receiver.updateAvailable() 160 | } else if (message === MessageToWebApp.PerformAutomatedBackup) { 161 | device.downloadBackup() 162 | } else if (message === MessageToWebApp.FinishedSavingBackup) { 163 | receiver.didFinishBackup(data.success) 164 | } 165 | }) 166 | } 167 | 168 | async function loadZipLibrary() { 169 | // load zip library (for exporting items as zip) 170 | const scriptTag = document.createElement('script') 171 | scriptTag.src = './vendor/zip/zip.js' 172 | scriptTag.async = true 173 | const headTag = document.getElementsByTagName('head')[0] 174 | headTag.appendChild(scriptTag) 175 | scriptTag.onload = () => { 176 | window.zip.workerScriptsPath = './vendor/zip/' 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /app/javascripts/Renderer/grantLinuxPasswordsAccess.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron') 2 | import { MessageToMainProcess } from '../Shared/IpcMessages' 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | document.getElementById('use-storage-button').addEventListener('click', () => { 6 | ipcRenderer.send(MessageToMainProcess.UseLocalstorageForKeychain) 7 | }) 8 | 9 | document.getElementById('quit-button').addEventListener('click', () => { 10 | ipcRenderer.send(MessageToMainProcess.Quit) 11 | }) 12 | 13 | const learnMoreButton = document.getElementById('learn-more') 14 | learnMoreButton.addEventListener('click', (event) => { 15 | ipcRenderer.send(MessageToMainProcess.LearnMoreAboutKeychainAccess) 16 | event.preventDefault() 17 | const moreInfo = document.getElementById('more-info') 18 | moreInfo.style.display = 'block' 19 | learnMoreButton.style.display = 'none' 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /app/javascripts/Shared/CommandLineArgs.ts: -------------------------------------------------------------------------------- 1 | export const CommandLineArgs = { 2 | Testing: '--testing-INSECURE', 3 | UserDataPath: '--experimental-user-data-path', 4 | } 5 | -------------------------------------------------------------------------------- /app/javascripts/Shared/IpcMessages.ts: -------------------------------------------------------------------------------- 1 | export enum MessageToWebApp { 2 | UpdateAvailable = 'update-available', 3 | PerformAutomatedBackup = 'download-backup', 4 | FinishedSavingBackup = 'finished-saving-backup', 5 | WindowBlurred = 'window-blurred', 6 | WindowFocused = 'window-focused', 7 | InstallComponentComplete = 'install-component-complete', 8 | } 9 | 10 | export enum MessageToMainProcess { 11 | UseLocalstorageForKeychain = 'UseLocalstorageForKeychain', 12 | LearnMoreAboutKeychainAccess = 'LearnMoreAboutKeychainAccess', 13 | Quit = 'Quit', 14 | } 15 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "standard-notes", 3 | "productName": "Standard Notes", 4 | "description": "An end-to-end encrypted notes app for digitalists and professionals.", 5 | "author": "Standard Notes ", 6 | "version": "3.20.2", 7 | "main": "./dist/index.js", 8 | "dependencies": { 9 | "keytar": "^7.9.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/stylesheets/renderer.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --sn-desktop-titlebar-height: 35px; 3 | --sn-desktop-titlebar-icon-font-size: 16px; 4 | } 5 | 6 | /* To offset frameless window nav buttons on Mac */ 7 | .mac-desktop #editor-column, 8 | .mac-desktop #notes-column { 9 | transition: 0.15s padding ease; 10 | } 11 | 12 | .mac-desktop #app.collapsed-notes.collapsed-navigation #editor-column { 13 | padding-top: 18px; 14 | } 15 | 16 | .mac-desktop #app.collapsed-navigation #notes-column { 17 | padding-top: 18px; 18 | } 19 | 20 | panel-resizer { 21 | -webkit-app-region: no-drag; 22 | } 23 | 24 | #desktop-title-bar { 25 | -webkit-app-region: drag; 26 | padding: 0; 27 | margin: 0; 28 | height: var(--sn-desktop-titlebar-height); 29 | line-height: var(--sn-desktop-titlebar-height); 30 | vertical-align: middle; 31 | background: var(--sn-stylekit-contrast-background-color) !important; 32 | border-bottom: 1px solid var(--sn-stylekit-contrast-border-color); 33 | display: flex; 34 | justify-content: space-between; 35 | z-index: 99999; 36 | position: absolute; 37 | width: 100%; 38 | top: 0; 39 | } 40 | 41 | #desktop-title-bar button { 42 | -webkit-app-region: no-drag; 43 | margin: 0; 44 | background: none; 45 | border: none; 46 | padding: 0 10px; 47 | color: var(--sn-stylekit-contrast-foreground-color); 48 | vertical-align: middle; 49 | height: 100%; 50 | } 51 | 52 | #desktop-title-bar button svg { 53 | max-width: var(--sn-desktop-titlebar-icon-font-size); 54 | } 55 | 56 | #desktop-title-bar button:hover svg, 57 | #desktop-title-bar button:focus svg { 58 | color: var(--sn-stylekit-info-color); 59 | } 60 | #desktop-title-bar button:focus { 61 | box-shadow: none; 62 | } 63 | 64 | #desktop-title-bar .title-bar-left-buttons, 65 | #desktop-title-bar .title-bar-right-buttons { 66 | font-size: 0; 67 | } 68 | 69 | /* Required for BrowserWindow titleBarStyle: 'hiddenInset' */ 70 | .mac-desktop #navigation, 71 | .mac-desktop #navigation .section-title-bar, 72 | .mac-desktop #notes-title-bar, 73 | .mac-desktop #editor-title-bar, 74 | .mac-desktop #lock-screen { 75 | -webkit-app-region: drag; 76 | } 77 | 78 | input, 79 | #navigation #navigation-content, 80 | .component-view-container, 81 | .panel-resizer { 82 | -webkit-app-region: no-drag; 83 | } 84 | -------------------------------------------------------------------------------- /app/vendor/zip/z-worker.js: -------------------------------------------------------------------------------- 1 | /* jshint worker:true */ 2 | (function main(global) { 3 | "use strict"; 4 | 5 | if (global.zWorkerInitialized) 6 | throw new Error('z-worker.js should be run only once'); 7 | global.zWorkerInitialized = true; 8 | 9 | addEventListener("message", function(event) { 10 | var message = event.data, type = message.type, sn = message.sn; 11 | var handler = handlers[type]; 12 | if (handler) { 13 | try { 14 | handler(message); 15 | } catch (e) { 16 | onError(type, sn, e); 17 | } 18 | } 19 | //for debug 20 | //postMessage({type: 'echo', originalType: type, sn: sn}); 21 | }); 22 | 23 | var handlers = { 24 | importScripts: doImportScripts, 25 | newTask: newTask, 26 | append: processData, 27 | flush: processData, 28 | }; 29 | 30 | // deflater/inflater tasks indexed by serial numbers 31 | var tasks = {}; 32 | 33 | function doImportScripts(msg) { 34 | if (msg.scripts && msg.scripts.length > 0) 35 | importScripts.apply(undefined, msg.scripts); 36 | postMessage({type: 'importScripts'}); 37 | } 38 | 39 | function newTask(msg) { 40 | var CodecClass = global[msg.codecClass]; 41 | var sn = msg.sn; 42 | if (tasks[sn]) 43 | throw Error('duplicated sn'); 44 | tasks[sn] = { 45 | codec: new CodecClass(msg.options), 46 | crcInput: msg.crcType === 'input', 47 | crcOutput: msg.crcType === 'output', 48 | crc: new Crc32(), 49 | }; 50 | postMessage({type: 'newTask', sn: sn}); 51 | } 52 | 53 | // performance may not be supported 54 | var now = global.performance ? global.performance.now.bind(global.performance) : Date.now; 55 | 56 | function processData(msg) { 57 | var sn = msg.sn, type = msg.type, input = msg.data; 58 | var task = tasks[sn]; 59 | // allow creating codec on first append 60 | if (!task && msg.codecClass) { 61 | newTask(msg); 62 | task = tasks[sn]; 63 | } 64 | var isAppend = type === 'append'; 65 | var start = now(); 66 | var output; 67 | if (isAppend) { 68 | try { 69 | output = task.codec.append(input, function onprogress(loaded) { 70 | postMessage({type: 'progress', sn: sn, loaded: loaded}); 71 | }); 72 | } catch (e) { 73 | delete tasks[sn]; 74 | throw e; 75 | } 76 | } else { 77 | delete tasks[sn]; 78 | output = task.codec.flush(); 79 | } 80 | var codecTime = now() - start; 81 | 82 | start = now(); 83 | if (input && task.crcInput) 84 | task.crc.append(input); 85 | if (output && task.crcOutput) 86 | task.crc.append(output); 87 | var crcTime = now() - start; 88 | 89 | var rmsg = {type: type, sn: sn, codecTime: codecTime, crcTime: crcTime}; 90 | var transferables = []; 91 | if (output) { 92 | rmsg.data = output; 93 | transferables.push(output.buffer); 94 | } 95 | if (!isAppend && (task.crcInput || task.crcOutput)) 96 | rmsg.crc = task.crc.get(); 97 | 98 | // posting a message with transferables will fail on IE10 99 | try { 100 | postMessage(rmsg, transferables); 101 | } catch(ex) { 102 | postMessage(rmsg); // retry without transferables 103 | } 104 | } 105 | 106 | function onError(type, sn, e) { 107 | var msg = { 108 | type: type, 109 | sn: sn, 110 | error: formatError(e) 111 | }; 112 | postMessage(msg); 113 | } 114 | 115 | function formatError(e) { 116 | return { message: e.message, stack: e.stack }; 117 | } 118 | 119 | // Crc32 code copied from file zip.js 120 | function Crc32() { 121 | this.crc = -1; 122 | } 123 | Crc32.prototype.append = function append(data) { 124 | var crc = this.crc | 0, table = this.table; 125 | for (var offset = 0, len = data.length | 0; offset < len; offset++) 126 | crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF]; 127 | this.crc = crc; 128 | }; 129 | Crc32.prototype.get = function get() { 130 | return ~this.crc; 131 | }; 132 | Crc32.prototype.table = (function() { 133 | var i, j, t, table = []; // Uint32Array is actually slower than [] 134 | for (i = 0; i < 256; i++) { 135 | t = i; 136 | for (j = 0; j < 8; j++) 137 | if (t & 1) 138 | t = (t >>> 1) ^ 0xEDB88320; 139 | else 140 | t = t >>> 1; 141 | table[i] = t; 142 | } 143 | return table; 144 | })(); 145 | 146 | // "no-op" codec 147 | function NOOP() {} 148 | global.NOOP = NOOP; 149 | NOOP.prototype.append = function append(bytes, onprogress) { 150 | return bytes; 151 | }; 152 | NOOP.prototype.flush = function flush() {}; 153 | })(this); 154 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | 4 | const presets = [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | targets: { 9 | electron: 9, 10 | }, 11 | }, 12 | ], 13 | ]; 14 | 15 | const ignore = [ 16 | './app/vendor', 17 | './app/compiled', 18 | './app/assets', 19 | './app/stylesheets', 20 | './app/dist', 21 | './app/node_modules', 22 | './node_modules', 23 | './package.json', 24 | './npm-debug.log', 25 | ]; 26 | 27 | return { 28 | ignore, 29 | presets, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /build/entitlements.mac.inherit.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.ico -------------------------------------------------------------------------------- /build/icon.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_128x128.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_16x16.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_256x256.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_32x32.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_512x512.png -------------------------------------------------------------------------------- /build/icon.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /build/icon/Icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/build/icon/Icon-512x512.png -------------------------------------------------------------------------------- /build/installer.nsh: -------------------------------------------------------------------------------- 1 | !macro customInit 2 | ; Delete any previous uninstall registry key to ensure the installer doesn't hang at 30% 3 | ; https://github.com/electron-userland/electron-builder/issues/4057#issuecomment-557570476 4 | ; https://github.com/electron-userland/electron-builder/issues/4092 5 | DeleteRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{${UNINSTALL_APP_KEY}}" 6 | DeleteRegKey HKCU "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" 7 | !macroend -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | owner: standardnotes 2 | repo: desktop 3 | provider: github 4 | updaterCacheDirName: standard-notes-updater 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "standard-notes", 3 | "main": "./app/dist/index.js", 4 | "version": "3.20.2", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/standardnotes/desktop" 8 | }, 9 | "license": "AGPL-3.0-or-later", 10 | "scripts": { 11 | "build:remove-unpacked": "rimraf dist/{linux-*,mac,win-*}", 12 | "build:web": "cd web && rimraf node_modules && yarn --ignore-engines && yarn run bundle:desktop", 13 | "build": "yarn lint && yarn build:web && yarn run webpack --config webpack.prod.js", 14 | "change-version": "node scripts/change-version.mjs", 15 | "clean:build": "rimraf app/dist/", 16 | "clean:tests": "rimraf test/data/tmp/", 17 | "clean": "npm-run-all --parallel clean:*", 18 | "dev:web": "cd web && yarn run watch:desktop", 19 | "dev": "NODE_ENV=development webpack --config webpack.dev.js --watch", 20 | "format": "prettier --write .", 21 | "lint:eslint": "eslint app/index.ts app/application.ts app/javascripts/**/*.ts", 22 | "lint:formatting": "prettier --check .", 23 | "lint:types": "tsc --noEmit", 24 | "lint": "npm-run-all --parallel lint:*", 25 | "postinstall": "electron-builder install-app-deps", 26 | "release": "node scripts/build.mjs mainstream", 27 | "release:mac": "node scripts/build.mjs mac", 28 | "setup": "yarn --ignore-engines && yarn --ignore-engines --cwd ./app && git submodule update --init && yarn --ignore-engines --cwd ./web", 29 | "start": "electron ./app --enable-logging --icon _icon/icon.png", 30 | "test": "rimraf test/data/tmp && ava", 31 | "update:web": "cd web && git checkout main && git pull && cd ..", 32 | "restore:web": "rm -rf web && git submodule add --force https://github.com/standardnotes/web.git" 33 | }, 34 | "dependencies": { 35 | "axios": "^0.27.2", 36 | "compare-versions": "^4.1.3", 37 | "decrypt": "github:standardnotes/decrypt#master", 38 | "electron-log": "^4.4.6", 39 | "electron-updater": "^5.0.1", 40 | "fs-extra": "^10.1.0", 41 | "mime-types": "^2.1.35", 42 | "mobx": "^6.5.0" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.17.10", 46 | "@babel/preset-env": "^7.17.10", 47 | "@commitlint/config-conventional": "^16.2.4", 48 | "@electron/remote": "^2.0.8", 49 | "@standardnotes/electron-clear-data": "1.1.1", 50 | "@types/lodash": "^4.14.182", 51 | "@types/mime-types": "^2.1.1", 52 | "@types/node": "^17.0.31", 53 | "@types/proxyquire": "^1.3.28", 54 | "@types/yauzl": "^2.10.0", 55 | "@typescript-eslint/eslint-plugin": "^5.22.0", 56 | "@typescript-eslint/parser": "^5.22.0", 57 | "ava": "^4.2.0", 58 | "babel-eslint": "^10.1.0", 59 | "babel-loader": "^8.2.5", 60 | "commitlint": "^16.2.4", 61 | "copy-webpack-plugin": "^10.2.4", 62 | "dotenv": "^16.0.0", 63 | "electron": "17.4.2", 64 | "electron-builder": "23.0.3", 65 | "electron-notarize": "^1.2.1", 66 | "eslint": "^8.14.0", 67 | "eslint-config-prettier": "^8.5.0", 68 | "eslint-plugin-import": "^2.26.0", 69 | "eslint-plugin-node": "^11.1.0", 70 | "eslint-plugin-promise": "^6.0.0", 71 | "file-loader": "^6.2.0", 72 | "husky": "^7.0.4", 73 | "lodash": "^4.17.21", 74 | "mime-types": "^2.1.35", 75 | "npm-run-all": "^4.1.5", 76 | "pre-push": "^0.1.2", 77 | "prettier": "^2.6.2", 78 | "proxyquire": "^2.1.3", 79 | "rimraf": "^3.0.2", 80 | "terser-webpack-plugin": "^5.3.1", 81 | "ts-loader": "^9.3.0", 82 | "ts-node": "^10.7.0", 83 | "typescript": "^4.6.4", 84 | "webpack": "^5.72.0", 85 | "webpack-cli": "^4.9.2", 86 | "webpack-merge": "^5.8.0" 87 | }, 88 | "build": { 89 | "appId": "org.standardnotes.standardnotes", 90 | "artifactName": "${name}-${version}-${os}-${arch}.${ext}", 91 | "afterSign": "./scripts/afterSignHook.js", 92 | "files": [ 93 | "compiled/**/*", 94 | "vendor/**/*", 95 | "dist/**/*", 96 | "stylesheets/**/*", 97 | "assets/**/*", 98 | "icon/**/*" 99 | ], 100 | "protocols": [ 101 | { 102 | "name": "Standard Notes", 103 | "schemes": [ 104 | "standardnotes" 105 | ] 106 | } 107 | ], 108 | "mac": { 109 | "category": "public.app-category.productivity", 110 | "hardenedRuntime": true, 111 | "entitlements": "./build/entitlements.mac.inherit.plist", 112 | "entitlementsInherit": "./build/entitlements.mac.inherit.plist", 113 | "target": [ 114 | "dmg", 115 | "zip" 116 | ] 117 | }, 118 | "win": { 119 | "certificateSubjectName": "Standard Notes Ltd.", 120 | "publisherName": "Standard Notes Ltd.", 121 | "signDlls": true 122 | }, 123 | "nsis": { 124 | "deleteAppDataOnUninstall": true 125 | }, 126 | "linux": { 127 | "category": "Office", 128 | "icon": "build/icon/", 129 | "desktop": { 130 | "StartupWMClass": "standard notes" 131 | }, 132 | "target": [ 133 | "AppImage" 134 | ] 135 | }, 136 | "snap": { 137 | "plugs": [ 138 | "default", 139 | "password-manager-service" 140 | ] 141 | } 142 | }, 143 | "husky": { 144 | "hooks": { 145 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 146 | } 147 | }, 148 | "ava": { 149 | "concurrency": 0, 150 | "extensions": [ 151 | "ts" 152 | ], 153 | "files": [ 154 | "test/*.spec.ts" 155 | ], 156 | "require": [ 157 | "ts-node/register/transpile-only" 158 | ], 159 | "verbose": true, 160 | "serial": true 161 | }, 162 | "pre-push": [ 163 | "lint" 164 | ] 165 | } 166 | -------------------------------------------------------------------------------- /scripts/afterSignHook.js: -------------------------------------------------------------------------------- 1 | /** @see: https://medium.com/@TwitterArchiveEraser/notarize-electron-apps-7a5f988406db */ 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const electronNotarize = require('electron-notarize'); 6 | 7 | module.exports = async function (params) { 8 | const platformName = params.electronPlatformName; 9 | // Only notarize the app on macOS. 10 | if (platformName !== 'darwin') { 11 | return; 12 | } 13 | console.log('afterSign hook triggered'); 14 | 15 | const { appId } = JSON.parse(await fs.promises.readFile('./package.json')).build; 16 | 17 | const appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`); 18 | await fs.promises.access(appPath); 19 | 20 | console.log(`Notarizing ${appId} found at ${appPath}`); 21 | 22 | try { 23 | electronNotarize 24 | .notarize({ 25 | appBundleId: appId, 26 | appPath: appPath, 27 | appleId: process.env.notarizeAppleId, 28 | appleIdPassword: process.env.notarizeAppleIdPassword, 29 | }) 30 | .then(() => { 31 | console.log(`Done notarizing ${appId}`); 32 | }); 33 | } catch (error) { 34 | console.error(error); 35 | throw error; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /scripts/build.mjs: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import fs from 'fs' 3 | 4 | async function buildTargets(targets) { 5 | console.log('Building targets: ', targets) 6 | await runCommand(Command('yarn run lint')) 7 | await runCommand(Command('yarn clean:build')) 8 | await runCommand(Command('yarn run build:web')) 9 | 10 | for (const group of CompileGroups) { 11 | let didCompileGroup = false 12 | for (const target of targets) { 13 | if (group.targets.includes(target)) { 14 | if (!didCompileGroup) { 15 | await runCommand(group.compileCommand) 16 | didCompileGroup = true 17 | } 18 | const buildCommands = BuildCommands[target] 19 | for (const buildCommand of buildCommands) { 20 | await runCommand(buildCommand) 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | function runCommand(commandObj) { 28 | return new Promise((resolve, reject) => { 29 | const { prompt, extraEnv } = commandObj 30 | console.log(prompt, Object.keys(extraEnv).length > 0 ? extraEnv : '') 31 | const [command, ...args] = prompt.split(' ') 32 | const options = { env: Object.assign({}, process.env, extraEnv) } 33 | const child = spawn(command, args, options) 34 | child.stdout.pipe(process.stdout) 35 | child.stderr.pipe(process.stderr) 36 | child.on('error', reject) 37 | child.on('close', (code) => { 38 | if (code > 0) { 39 | reject(code) 40 | } else { 41 | resolve(code) 42 | } 43 | }) 44 | }) 45 | } 46 | 47 | const Targets = { 48 | Appimage: 'appimage', 49 | AppimageArm64: 'appimage-arm64', 50 | AppimageX64: 'appimage-x64', 51 | AppimageAll: 'appimage-all', 52 | Deb: 'deb', 53 | DebArm64: 'deb-arm64', 54 | Dir: 'dir', 55 | DirArm64: 'dir-arm64', 56 | Mac: 'mac', 57 | MacAll: 'mac-all', 58 | MacArm64: 'mac-arm64', 59 | Snap: 'snap', 60 | SnapArm64: 'snap-arm64', 61 | Windows: 'windows', 62 | } 63 | 64 | const MainstreamTargetGroup = 'mainstream' 65 | 66 | const TargetGroups = { 67 | all: [ 68 | Targets.AppimageAll, 69 | Targets.Deb, 70 | Targets.DebArm64, 71 | Targets.Dir, 72 | Targets.DirArm64, 73 | Targets.MacAll, 74 | Targets.Snap, 75 | Targets.SnapArm64, 76 | Targets.Windows, 77 | ], 78 | [MainstreamTargetGroup]: [ 79 | Targets.Windows, 80 | Targets.AppimageAll, 81 | Targets.Deb, 82 | Targets.Snap, 83 | Targets.DebArm64, 84 | Targets.MacAll, 85 | ], 86 | mac: [Targets.MacArm64], 87 | } 88 | 89 | const arm64Env = { npm_config_target_arch: 'arm64' } 90 | 91 | const Command = function (prompt, extraEnv = {}) { 92 | return { 93 | prompt, 94 | extraEnv, 95 | } 96 | } 97 | 98 | const CompileGroups = [ 99 | { 100 | compileCommand: Command('yarn run webpack --config webpack.prod.js'), 101 | targets: [ 102 | Targets.Appimage, 103 | Targets.AppimageX64, 104 | Targets.AppimageArm64, 105 | Targets.AppimageAll, 106 | Targets.Mac, 107 | Targets.MacArm64, 108 | Targets.MacAll, 109 | Targets.Dir, 110 | Targets.Windows, 111 | ], 112 | }, 113 | { 114 | compileCommand: Command('yarn run webpack --config webpack.prod.js --env deb'), 115 | targets: [Targets.Deb], 116 | }, 117 | { 118 | compileCommand: Command('yarn run webpack --config webpack.prod.js --env deb', arm64Env), 119 | targets: [Targets.DebArm64], 120 | }, 121 | { 122 | compileCommand: Command('yarn run webpack --config webpack.prod.js', arm64Env), 123 | targets: [Targets.DirArm64], 124 | }, 125 | { 126 | compileCommand: Command('yarn run webpack --config webpack.prod.js --env snap'), 127 | targets: [Targets.Snap], 128 | }, 129 | { 130 | compileCommand: Command('yarn run webpack --config webpack.prod.js --env snap', arm64Env), 131 | targets: [Targets.SnapArm64], 132 | }, 133 | ] 134 | 135 | const BuildCommands = { 136 | [Targets.Appimage]: [ 137 | Command('yarn run electron-builder --linux --x64 --ia32 -c.linux.target=AppImage --publish=never'), 138 | ], 139 | [Targets.AppimageX64]: [Command('yarn run electron-builder --linux --x64 -c.linux.target=AppImage --publish=never')], 140 | [Targets.AppimageArm64]: [ 141 | Command('yarn run electron-builder --linux --arm64 -c.linux.target=AppImage --publish=never'), 142 | ], 143 | [Targets.AppimageAll]: [ 144 | Command('yarn run electron-builder --linux --arm64 --x64 --ia32 -c.linux.target=AppImage --publish=never'), 145 | ], 146 | [Targets.Deb]: [Command('yarn run electron-builder --linux --x64 --ia32 -c.linux.target=deb --publish=never')], 147 | [Targets.DebArm64]: [ 148 | Command('yarn run electron-builder --linux --arm64 -c.linux.target=deb --publish=never', { 149 | npm_config_target_arch: 'arm64', 150 | USE_SYSTEM_FPM: 'true', 151 | }), 152 | ], 153 | [Targets.Mac]: [ 154 | Command('yarn run electron-builder --mac --x64 --publish=never'), 155 | Command('node scripts/fix-mac-zip'), 156 | ], 157 | [Targets.MacArm64]: [Command('yarn run electron-builder --mac --arm64 --publish=never')], 158 | [Targets.MacAll]: [Command('yarn run electron-builder --macos --arm64 --x64 --publish=never')], 159 | [Targets.Dir]: [Command('yarn run electron-builder --linux --x64 -c.linux.target=dir --publish=never')], 160 | [Targets.DirArm64]: [ 161 | Command('yarn run electron-builder --linux --arm64 -c.linux.target=dir --publish=never', arm64Env), 162 | ], 163 | [Targets.Snap]: [Command('yarn run electron-builder --linux --x64 -c.linux.target=snap --publish=never')], 164 | [Targets.SnapArm64]: [ 165 | Command('yarn run electron-builder --linux --arm64 -c.linux.target=snap --publish=never', { 166 | npm_config_target_arch: 'arm64', 167 | SNAPCRAFT_BUILD_ENVIRONMENT: 'host', 168 | }), 169 | ], 170 | [Targets.Windows]: [Command('yarn run electron-builder --windows --x64 --ia32 --publish=never')], 171 | } 172 | 173 | async function publishSnap() { 174 | const packageJson = await fs.promises.readFile('./package.json') 175 | const version = JSON.parse(packageJson).version 176 | await runCommand(Command(`snapcraft upload dist/standard-notes-${version}-linux-amd64.snap`)) 177 | } 178 | 179 | ;(async () => { 180 | try { 181 | const input = process.argv[2] 182 | let targets = input.split(',') 183 | 184 | console.log('Input targets:', targets) 185 | 186 | if (targets.length === 1) { 187 | if (TargetGroups[targets[0]]) { 188 | targets = TargetGroups[targets[0]] 189 | } 190 | } 191 | await buildTargets(targets) 192 | 193 | if (input === MainstreamTargetGroup) { 194 | await runCommand(Command('node scripts/sums.mjs')) 195 | await runCommand(Command('node scripts/create-draft-release.mjs')) 196 | await publishSnap() 197 | } 198 | } catch (e) { 199 | console.error(e) 200 | process.exitCode = 1 201 | } 202 | })() 203 | -------------------------------------------------------------------------------- /scripts/change-version.mjs: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process' 2 | ;(async () => { 3 | const version = process.argv[2] 4 | if (!version) { 5 | console.error('Must specify a version number.') 6 | process.exitCode = 1 7 | return 8 | } 9 | execSync(`yarn version --no-git-tag-version --new-version ${version}`) 10 | process.chdir('app') 11 | execSync(`yarn version --no-git-tag-version --new-version ${version}`) 12 | process.chdir('..') 13 | execSync('git add package.json app/package.json') 14 | execSync(`git commit -m "chore(version): ${version}"`) 15 | })() 16 | -------------------------------------------------------------------------------- /scripts/create-draft-release.mjs: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { getLatestBuiltFilesList } from './utils.mjs' 5 | ;(async () => { 6 | const files = await getLatestBuiltFilesList() 7 | files.push('SHA256SUMS') 8 | 9 | const versionNumber = JSON.parse(fs.readFileSync('./package.json')).version 10 | console.log('Creating draft release...') 11 | const child = spawn('gh', [ 12 | 'release', 13 | 'create', 14 | `v${versionNumber}`, 15 | ...files.map((name) => path.join('dist', name)), 16 | '--target', 17 | 'main', 18 | '--draft', 19 | '--prerelease', 20 | '--title', 21 | versionNumber, 22 | ]) 23 | child.stdout.pipe(process.stdout) 24 | child.stderr.pipe(process.stderr) 25 | })() 26 | -------------------------------------------------------------------------------- /scripts/fix-mac-zip.js: -------------------------------------------------------------------------------- 1 | /* 2 | * There is an issue with electron-builder generating invalid zip files for Catalina. 3 | * This is a script implementation of the following workaround: 4 | * https://snippets.cacher.io/snippet/354a3eb7b0dcbe711383 5 | */ 6 | 7 | if (process.platform !== 'darwin') { 8 | console.error(`this script (${__filename}) can only be run from a darwin platform.`); 9 | process.exitCode = 1; 10 | return; 11 | } 12 | 13 | const fs = require('fs'); 14 | const childProcess = require('child_process'); 15 | const yaml = require('js-yaml'); 16 | const assert = require('assert').strict; 17 | const os = require('os'); 18 | 19 | function exec(command) { 20 | console.log(command); 21 | return new Promise((resolve, reject) => { 22 | childProcess.exec(command, (err, stdout, stderr) => { 23 | if (err) reject(err); 24 | else if (stderr) reject(Error(stderr)); 25 | else resolve(stdout); 26 | }); 27 | }); 28 | } 29 | 30 | async function getBlockMapInfo(fileName) { 31 | return JSON.parse( 32 | await exec( 33 | './node_modules/app-builder-bin/mac/app-builder_amd64 blockmap' + 34 | ` -i ${fileName}` + 35 | ` -o ${os.tmpdir()}/a.zip` 36 | ) 37 | ); 38 | } 39 | 40 | (async () => { 41 | try { 42 | const { version } = JSON.parse(await fs.promises.readFile('app/package.json')); 43 | const zipName = `standard-notes-${version}-mac-x64.zip`; 44 | const zipPath = `dist/${zipName}`; 45 | console.log(`Removing ${zipPath}`); 46 | await fs.promises.unlink(zipPath); 47 | 48 | process.chdir('dist/mac'); 49 | const appName = process.argv.includes('--beta') 50 | ? 'Standard\\ Notes\\ \\(Beta\\).app' 51 | : 'Standard\\ Notes.app'; 52 | /** @see https://superuser.com/questions/574032/what-is-the-equivalent-unix-command-to-a-mac-osx-compress-menu-action */ 53 | await exec(`ditto -c -k --sequesterRsrc --keepParent ${appName} ../${zipName}`); 54 | process.chdir('../..'); 55 | 56 | const [blockMapInfo, latestVersionInfo] = await Promise.all([ 57 | getBlockMapInfo(zipPath), 58 | fs.promises.readFile('dist/latest-mac.yml').then(yaml.load), 59 | ]); 60 | const index = latestVersionInfo.files.findIndex((file) => file.url === zipName); 61 | assert(index >= 0); 62 | latestVersionInfo.files[index] = { 63 | ...latestVersionInfo.files[index], 64 | ...blockMapInfo, 65 | }; 66 | latestVersionInfo.sha512 = blockMapInfo.sha512; 67 | console.log('Writing new size, hash and blockMap size to dist/latest-mac.yml'); 68 | await fs.promises.writeFile( 69 | 'dist/latest-mac.yml', 70 | yaml.dump(latestVersionInfo, { 71 | lineWidth: Infinity, 72 | }), 73 | 'utf8' 74 | ); 75 | } catch (err) { 76 | console.error(err); 77 | process.exitCode = 1; 78 | } 79 | })(); 80 | -------------------------------------------------------------------------------- /scripts/sums.mjs: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import fs from 'fs' 3 | import { getLatestBuiltFilesList } from './utils.mjs' 4 | 5 | function sha256(filePath) { 6 | return new Promise((resolve, reject) => { 7 | fs.createReadStream(filePath) 8 | .pipe(crypto.createHash('sha256').setEncoding('hex')) 9 | .on('finish', function () { 10 | resolve(this.read()) 11 | }) 12 | .on('error', reject) 13 | }) 14 | } 15 | 16 | ;(async () => { 17 | console.log('Writing SHA256 sums to dist/SHA256SUMS') 18 | 19 | try { 20 | const files = await getLatestBuiltFilesList() 21 | 22 | process.chdir('dist') 23 | 24 | let hashes = await Promise.all( 25 | files.map(async (fileName) => { 26 | const hash = await sha256(fileName) 27 | return `${hash} ${fileName}` 28 | }), 29 | ) 30 | hashes = hashes.join('\n') 31 | await fs.promises.writeFile('SHA256SUMS', hashes) 32 | console.log(`Successfully wrote SHA256SUMS:\n${hashes}`) 33 | } catch (err) { 34 | console.error(err) 35 | process.exitCode = 1 36 | } 37 | })() 38 | -------------------------------------------------------------------------------- /scripts/utils.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | export async function getLatestBuiltFilesList() { 4 | const packageJson = await fs.promises.readFile('./package.json') 5 | const version = JSON.parse(packageJson).version 6 | return [ 7 | `standard-notes-${version}-mac-x64.zip`, 8 | `standard-notes-${version}-mac-x64.dmg`, 9 | `standard-notes-${version}-mac-x64.dmg.blockmap`, 10 | 11 | `standard-notes-${version}-mac-arm64.zip`, 12 | `standard-notes-${version}-mac-arm64.dmg`, 13 | `standard-notes-${version}-mac-arm64.dmg.blockmap`, 14 | 15 | `standard-notes-${version}-linux-i386.AppImage`, 16 | `standard-notes-${version}-linux-x86_64.AppImage`, 17 | `standard-notes-${version}-linux-amd64.snap`, 18 | 19 | `standard-notes-${version}-linux-arm64.deb`, 20 | `standard-notes-${version}-linux-arm64.AppImage`, 21 | 22 | `standard-notes-${version}-win-x64.exe`, 23 | `standard-notes-${version}-win-x64.exe.blockmap`, 24 | 25 | `standard-notes-${version}-win.exe`, 26 | `standard-notes-${version}-win.exe.blockmap`, 27 | 28 | `standard-notes-${version}-win-ia32.exe`, 29 | `standard-notes-${version}-win-ia32.exe.blockmap`, 30 | 31 | 'latest-linux-ia32.yml', 32 | 'latest-linux.yml', 33 | 'latest-linux-arm64.yml', 34 | 'latest-mac.yml', 35 | 'latest.yml', 36 | 'builder-effective-config.yaml', 37 | ] 38 | } 39 | 40 | export async function getBuiltx64SnapFilename() { 41 | const packageJson = await fs.promises.readFile('./package.json') 42 | const version = JSON.parse(packageJson).version 43 | return `standard-notes-${version}-linux-amd64.snap` 44 | } 45 | -------------------------------------------------------------------------------- /test/TestIpcMessage.ts: -------------------------------------------------------------------------------- 1 | export interface TestIPCMessage { 2 | id: number 3 | type: MessageType 4 | args: any[] 5 | } 6 | 7 | export interface TestIPCMessageResult { 8 | id: number 9 | resolve?: any 10 | reject?: any 11 | } 12 | 13 | export interface AppTestMessage { 14 | type: AppMessageType 15 | } 16 | 17 | export enum AppMessageType { 18 | Ready, 19 | WindowLoaded, 20 | SavedBackup, 21 | Log, 22 | } 23 | 24 | export enum MessageType { 25 | WindowCount, 26 | StoreData, 27 | StoreSettingsLocation, 28 | StoreSet, 29 | SetLocalStorageValue, 30 | AppMenuItems, 31 | SpellCheckerManager, 32 | SpellCheckerLanguages, 33 | ClickLanguage, 34 | BackupsAreEnabled, 35 | ToggleBackupsEnabled, 36 | BackupsLocation, 37 | PerformBackup, 38 | ChangeBackupsLocation, 39 | CopyDecryptScript, 40 | MenuReloaded, 41 | UpdateState, 42 | CheckForUpdate, 43 | UpdateManagerNotifiedStateChange, 44 | Relaunch, 45 | DataArchive, 46 | GetJSON, 47 | DownloadFile, 48 | AutoUpdateEnabled, 49 | HasReloadedMenu, 50 | AppStateCall, 51 | SignOut, 52 | } 53 | -------------------------------------------------------------------------------- /test/backupsManager.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import path from 'path' 3 | 4 | import anyTest, { TestFn } from 'ava' 5 | import { Driver, createDriver } from './driver' 6 | 7 | const test = anyTest as TestFn 8 | 9 | const BackupsDirectoryName = 'Standard Notes Backups' 10 | 11 | test.beforeEach(async (t) => { 12 | t.context = await createDriver() 13 | const backupsLocation = await t.context.backups.location() 14 | await fs.rmdir(backupsLocation, { recursive: true }) 15 | await t.context.backups.copyDecryptScript(backupsLocation) 16 | }) 17 | 18 | test.afterEach.always(async (t) => { 19 | await t.context.stop() 20 | }) 21 | 22 | /** 23 | * Depending on the current system load, performing a backup 24 | * might take a while 25 | */ 26 | const timeoutDuration = 20 * 1000 /** 20s */ 27 | 28 | function wait(duration = 1000) { 29 | return new Promise((resolve) => setTimeout(resolve, duration)) 30 | } 31 | 32 | test('saves incoming data to the backups folder', async (t) => { 33 | const data = 'Sample Data' 34 | const fileName = await t.context.backups.save(data) 35 | const backupsLocation = await t.context.backups.location() 36 | const files = await fs.readdir(backupsLocation) 37 | t.true(files.includes(fileName)) 38 | t.is(data, await fs.readFile(path.join(backupsLocation, fileName), 'utf8')) 39 | }) 40 | 41 | test('saves the decrypt script to the backups folder', async (t) => { 42 | const backupsLocation = await t.context.backups.location() 43 | await wait(300) /** Disk might be busy */ 44 | const files = await fs.readdir(backupsLocation) 45 | t.true(files.includes('decrypt.html')) 46 | }) 47 | 48 | test('performs a backup', async (t) => { 49 | t.timeout(timeoutDuration) 50 | 51 | await wait() 52 | await t.context.backups.perform() 53 | const backupsLocation = await t.context.backups.location() 54 | const files = await fs.readdir(backupsLocation) 55 | 56 | t.true(files.length >= 1) 57 | }) 58 | 59 | test('changes backups folder location', async (t) => { 60 | t.timeout(timeoutDuration) 61 | await wait() 62 | await t.context.backups.perform() 63 | let newLocation = path.join(t.context.userDataPath, 'newLocation') 64 | await fs.mkdir(newLocation) 65 | const currentLocation = await t.context.backups.location() 66 | const fileNames = await fs.readdir(currentLocation) 67 | await t.context.backups.changeLocation(newLocation) 68 | newLocation = path.join(newLocation, BackupsDirectoryName) 69 | t.deepEqual(fileNames, await fs.readdir(newLocation)) 70 | 71 | /** Assert that the setting was saved */ 72 | const data = await t.context.storage.dataOnDisk() 73 | t.is(data.backupsLocation, newLocation) 74 | 75 | /** Perform backup and make sure there is one more file in the directory */ 76 | await t.context.backups.perform() 77 | const newFileNames = await fs.readdir(newLocation) 78 | t.deepEqual(newFileNames.length, fileNames.length + 1) 79 | }) 80 | 81 | test('changes backups location to a child directory', async (t) => { 82 | t.timeout(timeoutDuration) 83 | 84 | await wait() 85 | await t.context.backups.perform() 86 | const currentLocation = await t.context.backups.location() 87 | const backups = await fs.readdir(currentLocation) 88 | 89 | t.is(backups.length, 2) /** 1 + decrypt script */ 90 | 91 | const newLocation = path.join(currentLocation, 'child_dir') 92 | await t.context.backups.changeLocation(newLocation) 93 | 94 | t.deepEqual(await fs.readdir(path.join(newLocation, BackupsDirectoryName)), backups) 95 | }) 96 | 97 | test('changing backups location to the same directory should not do anything', async (t) => { 98 | t.timeout(timeoutDuration) 99 | await wait() 100 | await t.context.backups.perform() 101 | await t.context.backups.perform() 102 | const currentLocation = await t.context.backups.location() 103 | let totalFiles = (await fs.readdir(currentLocation)).length 104 | t.is(totalFiles, 3) /** 2 + decrypt script */ 105 | await t.context.backups.changeLocation(currentLocation) 106 | totalFiles = (await fs.readdir(currentLocation)).length 107 | t.is(totalFiles, 3) 108 | }) 109 | 110 | test('backups are enabled by default', async (t) => { 111 | t.is(await t.context.backups.enabled(), true) 112 | }) 113 | 114 | test('does not save a backup when they are disabled', async (t) => { 115 | await t.context.backups.toggleEnabled() 116 | await t.context.windowLoaded 117 | /** Do not wait on this one as the backup shouldn't be triggered */ 118 | t.context.backups.perform() 119 | await wait() 120 | const backupsLocation = await t.context.backups.location() 121 | const files = await fs.readdir(backupsLocation) 122 | t.deepEqual(files, ['decrypt.html']) 123 | }) 124 | -------------------------------------------------------------------------------- /test/data/zip-file.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/desktop/62e99c1b6c5f31e36c5d8922a99d93212b6f84cc/test/data/zip-file.zip -------------------------------------------------------------------------------- /test/driver.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | import { ChildProcess, spawn } from 'child_process' 3 | import electronPath, { MenuItem } from 'electron' 4 | import path from 'path' 5 | import { deleteDir, ensureDirectoryExists, readJSONFile } from '../app/javascripts/Main/Utils/fileUtils' 6 | import { Language } from '../app/javascripts/Main/spellcheckerManager' 7 | import { StoreKeys } from '../app/javascripts/Main/store' 8 | import { UpdateState } from '../app/javascripts/Main/updateManager' 9 | import { CommandLineArgs } from '../app/javascripts/Shared/CommandLineArgs' 10 | import { AppMessageType, AppTestMessage, MessageType, TestIPCMessage, TestIPCMessageResult } from './TestIpcMessage' 11 | 12 | function spawnAppprocess(userDataPath: string) { 13 | const p = spawn( 14 | electronPath as any, 15 | [path.join(__dirname, '..'), CommandLineArgs.Testing, CommandLineArgs.UserDataPath, userDataPath], 16 | { 17 | stdio: ['inherit', 'inherit', 'inherit', 'ipc'], 18 | }, 19 | ) 20 | return p 21 | } 22 | 23 | class Driver { 24 | private appProcess: ChildProcess 25 | 26 | private calls: Array<{ 27 | resolve: (...args: any) => void 28 | reject: (...args: any) => void 29 | } | null> = [] 30 | 31 | private awaitedOnMessages: Array<{ 32 | type: AppMessageType 33 | resolve: (...args: any) => void 34 | }> = [] 35 | 36 | appReady: Promise 37 | windowLoaded: Promise 38 | 39 | constructor(readonly userDataPath: string) { 40 | this.appProcess = spawnAppprocess(userDataPath) 41 | this.appProcess.on('message', this.receive) 42 | this.appReady = this.waitOn(AppMessageType.Ready) 43 | this.windowLoaded = this.waitOn(AppMessageType.WindowLoaded) 44 | } 45 | 46 | private receive = (message: TestIPCMessageResult | AppTestMessage) => { 47 | if ('type' in message) { 48 | if (message.type === AppMessageType.Log) { 49 | console.log(message) 50 | } 51 | 52 | this.awaitedOnMessages = this.awaitedOnMessages.filter(({ type, resolve }) => { 53 | if (type === message.type) { 54 | resolve() 55 | return false 56 | } 57 | return true 58 | }) 59 | } 60 | 61 | if ('id' in message) { 62 | const call = this.calls[message.id]! 63 | this.calls[message.id] = null 64 | if (message.reject) { 65 | call.reject(message.reject) 66 | } else { 67 | call.resolve(message.resolve) 68 | } 69 | } 70 | } 71 | 72 | private waitOn = (messageType: AppMessageType) => { 73 | return new Promise((resolve) => { 74 | this.awaitedOnMessages.push({ 75 | type: messageType, 76 | resolve, 77 | }) 78 | }) 79 | } 80 | 81 | private send = (type: MessageType, ...args: any): Promise => { 82 | const id = this.calls.length 83 | const message: TestIPCMessage = { 84 | id, 85 | type, 86 | args, 87 | } 88 | 89 | this.appProcess.send(message) 90 | 91 | return new Promise((resolve, reject) => { 92 | this.calls.push({ resolve, reject }) 93 | }) 94 | } 95 | 96 | windowCount = (): Promise => this.send(MessageType.WindowCount) 97 | 98 | appStateCall = (methodName: string, ...args: any): Promise => 99 | this.send(MessageType.AppStateCall, methodName, ...args) 100 | 101 | readonly window = { 102 | signOut: (): Promise => this.send(MessageType.SignOut), 103 | } 104 | 105 | readonly storage = { 106 | dataOnDisk: async (): Promise<{ [key in StoreKeys]: any }> => { 107 | const location = await this.send(MessageType.StoreSettingsLocation) 108 | return readJSONFile(location) 109 | }, 110 | dataLocation: (): Promise => this.send(MessageType.StoreSettingsLocation), 111 | setZoomFactor: (factor: number) => this.send(MessageType.StoreSet, 'zoomFactor', factor), 112 | setLocalStorageValue: (key: string, value: string): Promise => 113 | this.send(MessageType.SetLocalStorageValue, key, value), 114 | } 115 | 116 | readonly appMenu = { 117 | items: (): Promise => this.send(MessageType.AppMenuItems), 118 | clickLanguage: (language: Language) => this.send(MessageType.ClickLanguage, language), 119 | hasReloaded: () => this.send(MessageType.HasReloadedMenu), 120 | } 121 | 122 | readonly spellchecker = { 123 | manager: () => this.send(MessageType.SpellCheckerManager), 124 | languages: () => this.send(MessageType.SpellCheckerLanguages), 125 | } 126 | 127 | readonly backups = { 128 | enabled: (): Promise => this.send(MessageType.BackupsAreEnabled), 129 | toggleEnabled: (): Promise => this.send(MessageType.ToggleBackupsEnabled), 130 | location: (): Promise => this.send(MessageType.BackupsLocation), 131 | copyDecryptScript: async (location: string) => { 132 | await this.send(MessageType.CopyDecryptScript, location) 133 | }, 134 | changeLocation: (location: string) => this.send(MessageType.ChangeBackupsLocation, location), 135 | save: (data: any) => this.send(MessageType.DataArchive, data), 136 | perform: async () => { 137 | await this.windowLoaded 138 | await this.send(MessageType.PerformBackup) 139 | await this.waitOn(AppMessageType.SavedBackup) 140 | }, 141 | } 142 | 143 | readonly updates = { 144 | state: (): Promise => this.send(MessageType.UpdateState), 145 | autoUpdateEnabled: (): Promise => this.send(MessageType.AutoUpdateEnabled), 146 | check: () => this.send(MessageType.CheckForUpdate), 147 | } 148 | 149 | readonly net = { 150 | getJSON: (url: string) => this.send(MessageType.GetJSON, url), 151 | downloadFile: (url: string, filePath: string) => this.send(MessageType.DownloadFile, url, filePath), 152 | } 153 | 154 | stop = async () => { 155 | this.appProcess.kill() 156 | 157 | /** Give the process a little time before cleaning up */ 158 | await new Promise((resolve) => setTimeout(resolve, 150)) 159 | 160 | /** 161 | * Windows can throw EPERM or EBUSY errors when we try to delete the 162 | * user data directory too quickly. 163 | */ 164 | const maxTries = 5 165 | for (let i = 0; i < maxTries; i++) { 166 | try { 167 | await deleteDir(this.userDataPath) 168 | return 169 | } catch (error: any) { 170 | if (error.code === 'EPERM' || error.code === 'EBUSY') { 171 | await new Promise((resolve) => setTimeout(resolve, 300)) 172 | } else { 173 | throw error 174 | } 175 | } 176 | } 177 | throw new Error(`Couldn't delete user data directory after ${maxTries} tries`) 178 | } 179 | 180 | restart = async () => { 181 | this.appProcess.kill() 182 | this.appProcess = spawnAppprocess(this.userDataPath) 183 | this.appProcess.on('message', this.receive) 184 | this.appReady = this.waitOn(AppMessageType.Ready) 185 | this.windowLoaded = this.waitOn(AppMessageType.WindowLoaded) 186 | await this.appReady 187 | } 188 | } 189 | 190 | export type { Driver } 191 | 192 | export async function createDriver() { 193 | const userDataPath = path.join( 194 | __dirname, 195 | 'data', 196 | 'tmp', 197 | `userData-${Date.now()}-${Math.round(Math.random() * 10000)}`, 198 | ) 199 | await ensureDirectoryExists(userDataPath) 200 | const driver = new Driver(userDataPath) 201 | await driver.appReady 202 | return driver 203 | } 204 | -------------------------------------------------------------------------------- /test/extServer.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestFn } from 'ava' 2 | import { promises as fs } from 'fs' 3 | import http from 'http' 4 | import { AddressInfo } from 'net' 5 | import path from 'path' 6 | import proxyquire from 'proxyquire' 7 | import { ensureDirectoryExists } from '../app/javascripts/Main/Utils/FileUtils' 8 | import { initializeStrings } from '../app/javascripts/Main/strings' 9 | import { createTmpDir } from './testUtils' 10 | import makeFakePaths from './fakePaths' 11 | 12 | const test = anyTest as TestFn<{ 13 | server: http.Server 14 | host: string 15 | }> 16 | 17 | const tmpDir = createTmpDir(__filename) 18 | const FakePaths = makeFakePaths(tmpDir.path) 19 | 20 | let server: http.Server 21 | 22 | const { createExtensionsServer, normalizeFilePath } = proxyquire('../app/javascripts/Main/extServer', { 23 | './paths': { 24 | Paths: FakePaths, 25 | '@noCallThru': true, 26 | }, 27 | electron: { 28 | app: { 29 | getPath() { 30 | return tmpDir.path 31 | }, 32 | }, 33 | }, 34 | http: { 35 | createServer(...args: any) { 36 | server = http.createServer(...args) 37 | return server 38 | }, 39 | }, 40 | }) 41 | 42 | const extensionsDir = path.join(tmpDir.path, 'Extensions') 43 | 44 | initializeStrings('en') 45 | 46 | const log = console.log 47 | const error = console.error 48 | 49 | test.before(async (t) => { 50 | /** Prevent the extensions server from outputting anything */ 51 | // eslint-disable-next-line @typescript-eslint/no-empty-function 52 | // console.log = () => {} 53 | 54 | // eslint-disable-next-line @typescript-eslint/no-empty-function 55 | // console.error = () => {} 56 | 57 | await ensureDirectoryExists(extensionsDir) 58 | await new Promise((resolve) => { 59 | createExtensionsServer(resolve) 60 | t.context.server = server 61 | server.once('listening', () => { 62 | const { address, port } = server.address() as AddressInfo 63 | t.context.host = `http://${address}:${port}/` 64 | resolve(null) 65 | }) 66 | }) 67 | }) 68 | 69 | test.after((t): Promise => { 70 | /** Restore the console's functionality */ 71 | console.log = log 72 | console.error = error 73 | 74 | return Promise.all([tmpDir.clean(), new Promise((resolve) => t.context.server.close(resolve))]) 75 | }) 76 | 77 | test('serves the files in the Extensions directory over HTTP', (t) => { 78 | const data = { 79 | name: 'Boxes', 80 | meter: { 81 | 4: 4, 82 | }, 83 | syncopation: true, 84 | instruments: ['Drums', 'Bass', 'Vocals', { name: 'Piano', type: 'Electric' }], 85 | } 86 | 87 | return fs.writeFile(path.join(extensionsDir, 'file.json'), JSON.stringify(data)).then( 88 | () => 89 | new Promise((resolve) => { 90 | let serverData = '' 91 | http.get(t.context.host + 'Extensions/file.json').on('response', (response) => { 92 | response 93 | .setEncoding('utf-8') 94 | .on('data', (chunk) => { 95 | serverData += chunk 96 | }) 97 | .on('end', () => { 98 | t.deepEqual(data, JSON.parse(serverData)) 99 | resolve() 100 | }) 101 | }) 102 | }), 103 | ) 104 | }) 105 | 106 | test('does not serve files outside the Extensions directory', async (t) => { 107 | await new Promise((resolve) => { 108 | http.get(t.context.host + 'Extensions/../../../package.json').on('response', (response) => { 109 | t.is(response.statusCode, 500) 110 | resolve(true) 111 | }) 112 | }) 113 | }) 114 | 115 | test('returns a 404 for files that are not present', async (t) => { 116 | await new Promise((resolve) => { 117 | http.get(t.context.host + 'Extensions/nothing').on('response', (response) => { 118 | t.is(response.statusCode, 404) 119 | resolve(true) 120 | }) 121 | }) 122 | }) 123 | 124 | test('normalizes file paths to always point somewhere in the Extensions directory', (t) => { 125 | t.is(normalizeFilePath('/Extensions/test/yes', '127.0.0.1'), path.join(tmpDir.path, 'Extensions', 'test', 'yes')) 126 | t.is( 127 | normalizeFilePath('/Extensions/../../data/outside/the/extensions/directory'), 128 | path.join(tmpDir.path, 'Extensions', 'data', 'outside', 'the', 'extensions', 'directory'), 129 | ) 130 | }) 131 | -------------------------------------------------------------------------------- /test/fakePaths.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | export default function makeFakePaths(tmpDir: string) { 4 | const Paths = { 5 | get userDataDir(): string { 6 | return tmpDir 7 | }, 8 | get documentsDir(): string { 9 | return tmpDir 10 | }, 11 | get tempDir(): string { 12 | return tmpDir 13 | }, 14 | get extensionsDirRelative(): string { 15 | return 'Extensions' 16 | }, 17 | get extensionsDir(): string { 18 | return path.join(Paths.userDataDir, 'Extensions') 19 | }, 20 | get extensionsMappingJson(): string { 21 | return path.join(Paths.extensionsDir, 'mapping.json') 22 | }, 23 | get windowPositionJson(): string { 24 | return path.join(Paths.userDataDir, 'window-position.json') 25 | }, 26 | } 27 | 28 | return Paths 29 | } 30 | -------------------------------------------------------------------------------- /test/fileUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import test, { TestFn } from 'ava' 2 | import { promises as fs } from 'fs' 3 | import path from 'path' 4 | import { 5 | deleteDir, 6 | ensureDirectoryExists, 7 | extractNestedZip, 8 | FileDoesNotExist, 9 | moveDirContents, 10 | readJSONFile, 11 | writeJSONFile, 12 | } from '../app/javascripts/Main/Utils/FileUtils' 13 | 14 | const dataPath = path.join(__dirname, 'data') 15 | const tmpPath = path.join(dataPath, 'tmp', path.basename(__filename)) 16 | const zipFileDestination = path.join(tmpPath, 'zip-file-output') 17 | const root = path.join(tmpPath, 'tmp1') 18 | 19 | test.beforeEach(async () => { 20 | await ensureDirectoryExists(root) 21 | }) 22 | 23 | test.afterEach(async () => { 24 | await deleteDir(tmpPath) 25 | }) 26 | 27 | test('extracts a zip and unnests the folders by one level', async (t) => { 28 | await extractNestedZip(path.join(dataPath, 'zip-file.zip'), zipFileDestination) 29 | t.deepEqual(await fs.readdir(zipFileDestination), ['package.json', 'test-file.txt']) 30 | }) 31 | 32 | test('creates a directory even when parent directories are non-existent', async (t) => { 33 | await ensureDirectoryExists(path.join(root, 'tmp2', 'tmp3')) 34 | t.deepEqual(await fs.readdir(root), ['tmp2']) 35 | t.deepEqual(await fs.readdir(path.join(root, 'tmp2')), ['tmp3']) 36 | }) 37 | 38 | test('deletes a deeply-nesting directory', async (t) => { 39 | await ensureDirectoryExists(path.join(root, 'tmp2', 'tmp3')) 40 | await deleteDir(root) 41 | try { 42 | await fs.readdir(path.join(tmpPath, 'tmp1')) 43 | t.fail('Should not have been able to read') 44 | } catch (error: any) { 45 | if (error.code === FileDoesNotExist) { 46 | t.pass() 47 | } else { 48 | t.fail(error) 49 | } 50 | } 51 | }) 52 | 53 | test('moves the contents of one directory to the other', async (t) => { 54 | const fileNames = ['1.txt', '2.txt', '3.txt', 'nested/4.txt', 'nested/5.txt', 'nested/6.txt'] 55 | 56 | /** Create a temp directory and fill it with files */ 57 | const dir = path.join(tmpPath, 'move_contents_src') 58 | await ensureDirectoryExists(dir) 59 | await ensureDirectoryExists(path.join(dir, 'nested')) 60 | await Promise.all(fileNames.map((fileName) => fs.writeFile(path.join(dir, fileName), fileName))) 61 | 62 | /** Now move its contents */ 63 | const dest = path.join(tmpPath, 'move_contents_dest') 64 | await moveDirContents(dir, dest) 65 | await Promise.all( 66 | fileNames.map(async (fileName) => { 67 | const contents = await fs.readFile(path.join(dest, fileName), 'utf8') 68 | t.is(contents, fileName) 69 | }), 70 | ) 71 | }) 72 | 73 | test('moves the contents of one directory to a child directory', async (t) => { 74 | const srcFileNames = ['1.txt', '2.txt', '3.txt', 'nested/4.txt', 'nested/5.txt', 'nested/6.txt'] 75 | const destFileNames = ['1.txt', '2.txt', '3.txt', '4.txt', '5.txt', '6.txt'] 76 | 77 | /** Create a temp directory and fill it with files */ 78 | const dir = path.join(tmpPath, 'move_contents_src') 79 | await ensureDirectoryExists(dir) 80 | await ensureDirectoryExists(path.join(dir, 'nested')) 81 | await Promise.all(srcFileNames.map((fileName) => fs.writeFile(path.join(dir, fileName), fileName))) 82 | 83 | /** Now move its contents */ 84 | const dest = path.join(dir, 'nested') 85 | await moveDirContents(dir, dest) 86 | 87 | /** Ensure everything is there */ 88 | t.deepEqual((await fs.readdir(dest)).sort(), destFileNames.sort()) 89 | await Promise.all( 90 | destFileNames.map(async (fileName, index) => { 91 | const contents = await fs.readFile(path.join(dest, fileName), 'utf8') 92 | t.is(contents, srcFileNames[index]) 93 | }), 94 | ) 95 | }) 96 | 97 | test('serializes and deserializes an object to the same values', async (t) => { 98 | const data = { 99 | meter: { 100 | 4: 4, 101 | }, 102 | chorus: { 103 | passengers: 2, 104 | destination: 'moon', 105 | activities: [{ type: 'play', environment: 'stars' }], 106 | }, 107 | } 108 | const filePath = path.join(tmpPath, 'data.json') 109 | await writeJSONFile(filePath, data) 110 | t.deepEqual(data, await readJSONFile(filePath)) 111 | }) 112 | -------------------------------------------------------------------------------- /test/menus.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestFn } from 'ava' 2 | import { MenuItem } from 'electron' 3 | import { AppName } from '../app/javascripts/Main/strings' 4 | import { createDriver, Driver } from './driver' 5 | 6 | const test = anyTest as TestFn<{ 7 | driver: Driver 8 | menuItems: MenuItem[] 9 | }> 10 | 11 | test.before(async (t) => { 12 | t.context.driver = await createDriver() 13 | }) 14 | 15 | test.after.always(async (t) => { 16 | await t.context.driver.stop() 17 | }) 18 | 19 | test.beforeEach(async (t) => { 20 | t.context.menuItems = await t.context.driver.appMenu.items() 21 | }) 22 | 23 | function findSpellCheckerLanguagesMenu(menuItems: MenuItem[]) { 24 | return menuItems.find((item) => { 25 | if (item.role?.toLowerCase() === 'editmenu') { 26 | return item?.submenu?.items?.find((item) => item.id === 'SpellcheckerLanguages') 27 | } 28 | }) 29 | } 30 | if (process.platform === 'darwin') { 31 | test('shows the App menu on Mac', (t) => { 32 | t.is(t.context.menuItems[0].role.toLowerCase(), 'appmenu') 33 | t.is(t.context.menuItems[0].label, AppName) 34 | }) 35 | 36 | test('hides the spellchecking submenu on Mac', (t) => { 37 | t.falsy(findSpellCheckerLanguagesMenu(t.context.menuItems)) 38 | }) 39 | } else { 40 | test('hides the App menu on Windows/Linux', (t) => { 41 | t.is(t.context.menuItems[0].role.toLowerCase(), 'editmenu') 42 | }) 43 | 44 | test('shows the spellchecking submenu on Windows/Linux', (t) => { 45 | const menu = findSpellCheckerLanguagesMenu(t.context.menuItems) 46 | t.truthy(menu) 47 | t.true(menu!.submenu!.items!.length > 0) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /test/networking.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestFn } from 'ava' 2 | import { promises as fs } from 'fs' 3 | import http from 'http' 4 | import { AddressInfo } from 'net' 5 | import path from 'path' 6 | import { createDriver, Driver } from './driver' 7 | import { createTmpDir } from './testUtils' 8 | 9 | const test = anyTest as TestFn 10 | 11 | const tmpDir = createTmpDir(__filename) 12 | 13 | const sampleData = { 14 | title: 'Diamond Dove', 15 | meter: { 16 | 4: 4, 17 | }, 18 | instruments: ['Piano', 'Chiptune'], 19 | } 20 | 21 | let server: http.Server 22 | let serverAddress: string 23 | 24 | test.before( 25 | (): Promise => 26 | Promise.all([ 27 | tmpDir.make(), 28 | new Promise((resolve) => { 29 | server = http.createServer((_req, res) => { 30 | res.write(JSON.stringify(sampleData)) 31 | res.end() 32 | }) 33 | server.listen(0, '127.0.0.1', () => { 34 | const { address, port } = server.address() as AddressInfo 35 | serverAddress = `http://${address}:${port}` 36 | resolve(null) 37 | }) 38 | }), 39 | ]), 40 | ) 41 | 42 | test.after((): Promise => Promise.all([tmpDir.clean(), new Promise((resolve) => server.close(resolve))])) 43 | 44 | test.beforeEach(async (t) => { 45 | t.context = await createDriver() 46 | }) 47 | test.afterEach((t) => t.context.stop()) 48 | 49 | test('downloads a JSON file', async (t) => { 50 | t.deepEqual(await t.context.net.getJSON(serverAddress), sampleData) 51 | }) 52 | 53 | test('downloads a folder to the specified location', async (t) => { 54 | const filePath = path.join(tmpDir.path, 'fileName.json') 55 | await t.context.net.downloadFile(serverAddress + '/file', filePath) 56 | const fileContents = await fs.readFile(filePath, 'utf8') 57 | t.is(JSON.stringify(sampleData), fileContents) 58 | }) 59 | -------------------------------------------------------------------------------- /test/packageManager.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { promises as fs } from 'fs' 3 | import path from 'path' 4 | import proxyquire from 'proxyquire' 5 | import { ensureDirectoryExists, readJSONFile } from '../app/javascripts/Main/Utils/FileUtils' 6 | import { createTmpDir } from './testUtils' 7 | import { AppName } from '../app/javascripts/Main/strings' 8 | import makeFakePaths from './fakePaths' 9 | import { PackageManagerInterface } from '../app/javascripts/Main/Packages/PackageManagerInterface' 10 | 11 | const tmpDir = createTmpDir(__filename) 12 | const FakePaths = makeFakePaths(tmpDir.path) 13 | 14 | const contentDir = path.join(tmpDir.path, 'Extensions') 15 | let downloadFileCallCount = 0 16 | 17 | const { initializePackageManager } = proxyquire('../app/javascripts/Main/packageManager', { 18 | './paths': { 19 | Paths: FakePaths, 20 | '@noCallThru': true, 21 | }, 22 | './networking': { 23 | /** Download a fake component file */ 24 | async downloadFile(_src: string, dest: string) { 25 | downloadFileCallCount += 1 26 | if (!path.normalize(dest).startsWith(tmpDir.path)) { 27 | throw new Error(`Bad download destination: ${dest}`) 28 | } 29 | await ensureDirectoryExists(path.dirname(dest)) 30 | await fs.copyFile(path.join(__dirname, 'data', 'zip-file.zip'), path.join(dest)) 31 | }, 32 | }, 33 | }) 34 | 35 | const fakeWebContents = { 36 | send(_eventName: string, { error }) { 37 | if (error) throw error 38 | }, 39 | } 40 | 41 | const name = 'Fake Component' 42 | const identifier = 'fake.component' 43 | const uuid = 'fake-component' 44 | const version = '1.0.0' 45 | const modifiers = Array(20) 46 | .fill(0) 47 | .map((_, i) => String(i).padStart(2, '0')) 48 | 49 | function fakeComponent({ deleted = false, modifier = '' } = {}) { 50 | return { 51 | uuid: uuid + modifier, 52 | deleted, 53 | content: { 54 | name: name + modifier, 55 | autoupdateDisabled: false, 56 | package_info: { 57 | version, 58 | identifier: identifier + modifier, 59 | download_url: 'https://standardnotes.com', 60 | url: 'https://standardnotes.com', 61 | latest_url: 'https://standardnotes.com', 62 | }, 63 | }, 64 | } 65 | } 66 | 67 | let packageManager: PackageManagerInterface 68 | 69 | const log = console.log 70 | const error = console.error 71 | 72 | test.before(async function () { 73 | /** Silence the package manager's output. */ 74 | // eslint-disable-next-line @typescript-eslint/no-empty-function 75 | console.log = () => {} 76 | // eslint-disable-next-line @typescript-eslint/no-empty-function 77 | console.error = () => {} 78 | await ensureDirectoryExists(contentDir) 79 | packageManager = await initializePackageManager(fakeWebContents) 80 | }) 81 | 82 | test.after.always(async function () { 83 | console.log = log 84 | console.error = error 85 | await tmpDir.clean() 86 | }) 87 | 88 | test.beforeEach(function () { 89 | downloadFileCallCount = 0 90 | }) 91 | 92 | test('installs multiple components', async (t) => { 93 | await packageManager.syncComponents(modifiers.map((modifier) => fakeComponent({ modifier }))) 94 | await new Promise((resolve) => setTimeout(resolve, 200)) 95 | 96 | const files = await fs.readdir(contentDir) 97 | t.is(files.length, 1 + modifiers.length) 98 | for (const modifier of modifiers) { 99 | t.true(files.includes(identifier + modifier)) 100 | } 101 | t.true(files.includes('mapping.json')) 102 | const mappingContents = await fs.readFile(path.join(contentDir, 'mapping.json'), 'utf8') 103 | 104 | t.deepEqual( 105 | JSON.parse(mappingContents), 106 | modifiers.reduce((acc, modifier) => { 107 | acc[uuid + modifier] = { 108 | location: path.join('Extensions', identifier + modifier), 109 | version, 110 | } 111 | return acc 112 | }, {}), 113 | ) 114 | 115 | const downloads = await fs.readdir(path.join(tmpDir.path, AppName, 'downloads')) 116 | t.is(downloads.length, modifiers.length) 117 | for (const modifier of modifiers) { 118 | t.true(downloads.includes(`${name + modifier}.zip`)) 119 | } 120 | 121 | for (const modifier of modifiers) { 122 | const componentFiles = await fs.readdir(path.join(contentDir, identifier + modifier)) 123 | t.is(componentFiles.length, 2) 124 | } 125 | }) 126 | 127 | test('uninstalls multiple components', async (t) => { 128 | await packageManager.syncComponents(modifiers.map((modifier) => fakeComponent({ deleted: true, modifier }))) 129 | await new Promise((resolve) => setTimeout(resolve, 200)) 130 | 131 | const files = await fs.readdir(contentDir) 132 | t.deepEqual(files, ['mapping.json']) 133 | 134 | t.deepEqual(await readJSONFile(path.join(contentDir, 'mapping.json')), {}) 135 | }) 136 | 137 | test("doesn't download anything when two install/uninstall tasks are queued", async (t) => { 138 | await Promise.all([ 139 | packageManager.syncComponents([fakeComponent({ deleted: false })]), 140 | packageManager.syncComponents([fakeComponent({ deleted: false })]), 141 | packageManager.syncComponents([fakeComponent({ deleted: true })]), 142 | ]) 143 | t.is(downloadFileCallCount, 1) 144 | }) 145 | 146 | test("Relies on download_url's version field to store the version number", async (t) => { 147 | await packageManager.syncComponents([fakeComponent()]) 148 | await new Promise((resolve) => setTimeout(resolve, 200)) 149 | 150 | const mappingFileVersion = JSON.parse(await fs.readFile(path.join(contentDir, 'mapping.json'), 'utf8'))[uuid].version 151 | 152 | const packageJsonVersion = JSON.parse( 153 | await fs.readFile(path.join(contentDir, identifier, 'package.json'), 'utf-8'), 154 | ).version 155 | 156 | t.not(mappingFileVersion, packageJsonVersion) 157 | t.is(mappingFileVersion, version) 158 | }) 159 | -------------------------------------------------------------------------------- /test/spellcheckerManager.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestFn } from 'ava' 2 | import { Driver, createDriver } from './driver' 3 | 4 | const StoreKeys = { 5 | SelectedSpellCheckerLanguageCodes: 'selectedSpellCheckerLanguageCodes', 6 | } 7 | 8 | const test = anyTest as TestFn 9 | 10 | test.before(async (t) => { 11 | t.context = await createDriver() 12 | }) 13 | 14 | test.after.always(async (t) => { 15 | await t.context.stop() 16 | }) 17 | 18 | if (process.platform === 'darwin') { 19 | test('does not create a manager on Mac', async (t) => { 20 | t.falsy(await t.context.spellchecker.manager()) 21 | }) 22 | } else { 23 | const language = 'cs' 24 | 25 | test("adds a clicked language menu item to the store and session's languages", async (t) => { 26 | await t.context.appMenu.clickLanguage(language as any) 27 | const data = await t.context.storage.dataOnDisk() 28 | t.true(data[StoreKeys.SelectedSpellCheckerLanguageCodes].includes(language)) 29 | t.true((await t.context.spellchecker.languages()).includes(language)) 30 | }) 31 | 32 | test("removes a clicked language menu item to the store's and session's languages", async (t) => { 33 | await t.context.appMenu.clickLanguage(language as any) 34 | const data = await t.context.storage.dataOnDisk() 35 | t.false(data[StoreKeys.SelectedSpellCheckerLanguageCodes].includes(language)) 36 | t.false((await t.context.spellchecker.languages()).includes(language)) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /test/storage.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestFn, ExecutionContext } from 'ava' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import proxyquire from 'proxyquire' 5 | import { timeout } from '../app/javascripts/Main/Utils/Utils' 6 | import { createDriver, Driver } from './driver' 7 | 8 | const { serializeStoreData } = proxyquire('../app/javascripts/Main/store', { 9 | './backupsManager': { 10 | '@noCallThru': true, 11 | }, 12 | '@electron': { 13 | '@noCallThru': true, 14 | }, 15 | '@electron/remote': { 16 | '@noCallThru': true, 17 | }, 18 | }) 19 | 20 | async function validateData(t: ExecutionContext) { 21 | const data = await t.context.storage.dataOnDisk() 22 | 23 | /** 24 | * There should always be 8 values in the store. 25 | * If one was added/removed intentionally, update this number 26 | */ 27 | const numberOfStoreKeys = 10 28 | t.is(Object.keys(data).length, numberOfStoreKeys) 29 | 30 | t.is(typeof data.isMenuBarVisible, 'boolean') 31 | 32 | t.is(typeof data.useSystemMenuBar, 'boolean') 33 | 34 | t.is(typeof data.backupsDisabled, 'boolean') 35 | 36 | t.is(typeof data.minimizeToTray, 'boolean') 37 | 38 | t.is(typeof data.enableAutoUpdates, 'boolean') 39 | 40 | t.is(typeof data.zoomFactor, 'number') 41 | t.true(data.zoomFactor > 0) 42 | 43 | t.is(typeof data.extServerHost, 'string') 44 | /** Must not throw */ 45 | const extServerHost = new URL(data.extServerHost) 46 | t.is(extServerHost.hostname, '127.0.0.1') 47 | t.is(extServerHost.protocol, 'http:') 48 | t.is(extServerHost.port, '45653') 49 | 50 | t.is(typeof data.backupsLocation, 'string') 51 | 52 | t.is(data.useNativeKeychain, null) 53 | 54 | if (process.platform === 'darwin') { 55 | t.is(data.selectedSpellCheckerLanguageCodes, null) 56 | } else { 57 | t.true(Array.isArray(data.selectedSpellCheckerLanguageCodes)) 58 | for (const language of data.selectedSpellCheckerLanguageCodes) { 59 | t.is(typeof language, 'string') 60 | } 61 | } 62 | } 63 | 64 | const test = anyTest as TestFn 65 | 66 | test.beforeEach(async (t) => { 67 | t.context = await createDriver() 68 | }) 69 | test.afterEach.always((t) => { 70 | return t.context.stop() 71 | }) 72 | 73 | test('has valid data', async (t) => { 74 | await validateData(t) 75 | }) 76 | 77 | test('recreates a missing data file', async (t) => { 78 | const location = await t.context.storage.dataLocation() 79 | /** Delete the store's backing file */ 80 | await fs.promises.unlink(location) 81 | await t.context.restart() 82 | await validateData(t) 83 | }) 84 | 85 | test('recovers from corrupted data', async (t) => { 86 | const location = await t.context.storage.dataLocation() 87 | /** Write bad data in the store's file */ 88 | await fs.promises.writeFile(location, '\uFFFF'.repeat(300)) 89 | await t.context.restart() 90 | await validateData(t) 91 | }) 92 | 93 | test('persists changes to disk after setting a value', async (t) => { 94 | const factor = 4.8 95 | await t.context.storage.setZoomFactor(factor) 96 | const diskData = await t.context.storage.dataOnDisk() 97 | t.is(diskData.zoomFactor, factor) 98 | }) 99 | 100 | test('serializes string sets to an array', (t) => { 101 | t.deepEqual( 102 | serializeStoreData({ 103 | set: new Set(['value']), 104 | } as any), 105 | JSON.stringify({ 106 | set: ['value'], 107 | }), 108 | ) 109 | }) 110 | 111 | test('deletes local storage data after signing out', async (t) => { 112 | function readLocalStorageContents() { 113 | return fs.promises.readFile(path.join(t.context.userDataPath, 'Local Storage', 'leveldb', '000003.log'), { 114 | encoding: 'utf8', 115 | }) 116 | } 117 | await t.context.windowLoaded 118 | await t.context.storage.setLocalStorageValue('foo', 'bar') 119 | let localStorageContents = await readLocalStorageContents() 120 | 121 | t.is(localStorageContents.includes('foo'), true) 122 | t.is(localStorageContents.includes('bar'), true) 123 | 124 | await timeout(1_000) 125 | await t.context.window.signOut() 126 | await timeout(1_000) 127 | localStorageContents = await readLocalStorageContents() 128 | t.is(localStorageContents.includes('foo'), false) 129 | t.is(localStorageContents.includes('bar'), false) 130 | }) 131 | -------------------------------------------------------------------------------- /test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { deleteDir, ensureDirectoryExists } from '../app/javascripts/Main/Utils/FileUtils' 3 | 4 | export function createTmpDir(name: string): { 5 | path: string 6 | make(): Promise 7 | clean(): Promise 8 | } { 9 | const tmpDirPath = path.join(__dirname, 'data', 'tmp', path.basename(name)) 10 | 11 | return { 12 | path: tmpDirPath, 13 | async make() { 14 | await ensureDirectoryExists(tmpDirPath) 15 | return tmpDirPath 16 | }, 17 | async clean() { 18 | await deleteDir(tmpDirPath) 19 | }, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/updates.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestFn } from 'ava' 2 | import { createDriver, Driver } from './driver' 3 | 4 | const test = anyTest as TestFn 5 | 6 | test.beforeEach(async (t) => { 7 | t.context = await createDriver() 8 | }) 9 | 10 | test.afterEach.always(async (t) => { 11 | await t.context.stop() 12 | }) 13 | 14 | test('has auto-updates enabled by default', async (t) => { 15 | t.true(await t.context.updates.autoUpdateEnabled()) 16 | }) 17 | 18 | test('reloads the menu after checking for an update', async (t) => { 19 | await t.context.updates.check() 20 | t.true(await t.context.appMenu.hasReloaded()) 21 | }) 22 | -------------------------------------------------------------------------------- /test/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { lowercaseDriveLetter } from '../app/javascripts/Main/Utils/Utils' 3 | 4 | test("lowerCaseDriverLetter converts the drive letter of a given file's path to lower case", (t) => { 5 | t.is(lowercaseDriveLetter('/C:/Lansing'), '/c:/Lansing') 6 | t.is(lowercaseDriveLetter('/c:/Bone Rage'), '/c:/Bone Rage') 7 | t.is(lowercaseDriveLetter('/C:/Give/Us/the/Gold'), '/c:/Give/Us/the/Gold') 8 | }) 9 | 10 | test('lowerCaseDriverLetter only changes a single drive letter', (t) => { 11 | t.is(lowercaseDriveLetter('C:/Hold Me In'), 'C:/Hold Me In') 12 | t.is(lowercaseDriveLetter('/Cd:/Egg Replacer'), '/Cd:/Egg Replacer') 13 | t.is(lowercaseDriveLetter('/C:radle of Rocks'), '/C:radle of Rocks') 14 | }) 15 | -------------------------------------------------------------------------------- /test/window.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestFn } from 'ava' 2 | import { createDriver, Driver } from './driver' 3 | 4 | const test = anyTest as TestFn 5 | 6 | test.before(async (t) => { 7 | t.context = await createDriver() 8 | }) 9 | 10 | test.after.always((t) => { 11 | return t.context.stop() 12 | }) 13 | 14 | test('Only has one window', async (t) => { 15 | t.is(await t.context.windowCount(), 1) 16 | }) 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "moduleResolution": "node", 5 | "types": ["node"], 6 | "allowJs": true, 7 | "noEmit": false, 8 | "sourceMap": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "typeRoots": ["./app/@types", "./node_modules/@types"], 13 | "baseUrl": ".", 14 | "paths": { 15 | "@web/*": ["./web/app/assets/javascripts/*"] 16 | } 17 | }, 18 | "include": ["app/**/*"], 19 | "exclude": ["node_modules", "app/dist"], 20 | "files": ["app/index.ts", "app/javascripts/Renderer/Renderer.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const env = process.env.NODE_ENV ?? 'production'; 2 | require('dotenv').config({ 3 | path: `.env.${env}`, 4 | }); 5 | 6 | const path = require('path'); 7 | const CopyPlugin = require('copy-webpack-plugin'); 8 | const webpack = require('webpack'); 9 | const { DefinePlugin } = require('webpack'); 10 | const TerserPlugin = require('terser-webpack-plugin'); 11 | 12 | module.exports = function ({ 13 | onlyTranspileTypescript = false, 14 | experimentalFeatures = false, 15 | snap = false, 16 | } = {}) { 17 | const moduleConfig = { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | use: [ 22 | 'babel-loader', 23 | { 24 | loader: 'ts-loader', 25 | options: { 26 | transpileOnly: onlyTranspileTypescript, 27 | }, 28 | }, 29 | ], 30 | }, 31 | { 32 | test: /\.js$/, 33 | exclude: /node_modules/, 34 | loader: 'babel-loader', 35 | }, 36 | { 37 | sideEffects: true, 38 | test: /\.(png|html)$/i, 39 | loader: 'file-loader', 40 | options: { 41 | name: '[name].[ext]', 42 | }, 43 | }, 44 | ], 45 | }; 46 | 47 | const resolve = { 48 | extensions: ['.ts', '.js'], 49 | alias: { 50 | '@web': path.resolve(__dirname, 'web/app/assets/javascripts'), 51 | }, 52 | }; 53 | 54 | const EXPERIMENTAL_FEATURES = JSON.stringify(experimentalFeatures); 55 | const IS_SNAP = JSON.stringify(snap ? true : false); 56 | 57 | const electronMainConfig = { 58 | entry: { 59 | index: './app/index.ts', 60 | }, 61 | output: { 62 | path: path.resolve(__dirname, 'app', 'dist'), 63 | filename: 'index.js', 64 | }, 65 | devtool: 'inline-cheap-source-map', 66 | target: 'electron-main', 67 | node: { 68 | __dirname: false, 69 | }, 70 | resolve, 71 | module: moduleConfig, 72 | externals: { 73 | keytar: 'commonjs keytar', 74 | }, 75 | optimization: { 76 | minimizer: [ 77 | new TerserPlugin({ 78 | exclude: ['vendor', 'web', 'node_modules'], 79 | }), 80 | ], 81 | }, 82 | plugins: [ 83 | new DefinePlugin({ 84 | EXPERIMENTAL_FEATURES, 85 | IS_SNAP, 86 | }), 87 | new CopyPlugin({ 88 | patterns: [ 89 | { 90 | from: 'app/vendor', 91 | to: 'vendor', 92 | }, 93 | { 94 | from: 'web/dist', 95 | to: 'standard-notes-web', 96 | }, 97 | { 98 | from: 'web/public/components', 99 | to: 'standard-notes-web/components', 100 | }, 101 | { 102 | from: 'app/node_modules', 103 | to: 'node_modules', 104 | }, 105 | { 106 | from: 'app/stylesheets/renderer.css', 107 | to: 'stylesheets/renderer.css', 108 | }, 109 | { 110 | from: 'app/icon', 111 | to: 'icon', 112 | }, 113 | ], 114 | }), 115 | ], 116 | }; 117 | 118 | const electronRendererConfig = { 119 | entry: { 120 | preload: './app/javascripts/Renderer/Preload.ts', 121 | renderer: './app/javascripts/Renderer/Renderer.ts', 122 | grantLinuxPasswordsAccess: './app/javascripts/Renderer/grantLinuxPasswordsAccess.js', 123 | }, 124 | output: { 125 | path: path.resolve(__dirname, 'app', 'dist', 'javascripts', 'renderer'), 126 | publicPath: '/', 127 | }, 128 | target: 'electron-renderer', 129 | devtool: 'inline-cheap-source-map', 130 | node: { 131 | __dirname: false, 132 | }, 133 | resolve, 134 | module: moduleConfig, 135 | externals: { 136 | electron: 'commonjs electron', 137 | }, 138 | plugins: [ 139 | new webpack.DefinePlugin({ 140 | DEFAULT_SYNC_SERVER: JSON.stringify( 141 | process.env.DEFAULT_SYNC_SERVER || 'https://api.standardnotes.com' 142 | ), 143 | PURCHASE_URL: JSON.stringify(process.env.PURCHASE_URL), 144 | PLANS_URL: JSON.stringify(process.env.PLANS_URL), 145 | DASHBOARD_URL: JSON.stringify(process.env.DASHBOARD_URL), 146 | EXPERIMENTAL_FEATURES, 147 | WEBSOCKET_URL: JSON.stringify(process.env.WEBSOCKET_URL), 148 | ENABLE_UNFINISHED_FEATURES: JSON.stringify(process.env.ENABLE_UNFINISHED_FEATURES), 149 | }), 150 | ], 151 | }; 152 | return [electronMainConfig, electronRendererConfig]; 153 | }; 154 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = (env) => 5 | common({ 6 | ...env, 7 | onlyTranspileTypescript: true, 8 | experimentalFeatures: true, 9 | }).map((config) => 10 | merge(config, { 11 | mode: 'development', 12 | devtool: 'inline-cheap-source-map', 13 | }) 14 | ); 15 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = (env) => 5 | common(env).map((config) => 6 | merge(config, { 7 | mode: 'production', 8 | devtool: 'source-map', 9 | }) 10 | ); 11 | --------------------------------------------------------------------------------