├── .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 | [](https://github.com/standardnotes/desktop/releases)
6 | [](https://github.com/standardnotes/desktop/blob/master/LICENSE)
7 | [](https://standardnotes.com/slack)
8 | [](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 |
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 |
39 |
40 |
Learn more
41 |
42 |
43 |
What's the difference?
44 |
45 | Using local storage, your account keys may be more easily accessible by third-party programs, unlike
46 | in your password manager which has additional protections built-in.
47 |
48 |
49 | In either cases, the strongest way to protect your account keys is to use a strong passcode, which
50 | will be used to encrypt your keys and prevent any software or operating system from reading them.
51 | If you plan on setting a passcode, you can safely use local storage.
52 |
53 |
54 |
Granting Standard Notes access to your system password service
55 |
56 | Note that
57 |
58 | granting access to your system password service will allow Standard Notes to read, write, and
59 | delete any of your saved passwords.
60 |
61 | Standard Notes will never use this privilege to do anything more than reading and writing to its own
62 | entry.
63 |
64 |
65 | Quit Standard Notes
66 | Open your software store (Ubuntu Software Center/Snap Store)
67 | In your installed apps list, click on Standard Notes
68 | Look for a Permissions button
69 |
70 | Make sure the permission associated with reading and writing passwords is checked
71 |
72 | Open Standard Notes again
73 |
74 |
75 | Granting Standard Notes access to your system password service from the command line
76 |
77 |
78 | Run the following command:
79 | snap connect standard-notes:password-manager-service
80 |
81 |
82 |
83 |
84 | Note: Password Service may also be referred to as keyring, saved passwords, stored passwords,
85 | password manager, passwords, or secrets, depending on your Linux configuration.
86 |
87 |
88 |
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 |
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 |
--------------------------------------------------------------------------------