├── .commitlintrc.json ├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .gitattributes ├── .github ├── issue_template.md ├── pull_request_template.md └── workflows │ ├── build.yml │ └── pr.yml ├── .gitignore ├── .husky └── commit-msg ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __mocks__ ├── @electron │ └── remote.js ├── electron-store.js ├── electron.js ├── fileMock.js └── plugins.js ├── app ├── background │ ├── background.js │ ├── createWindow.js │ └── index.html ├── initAutoUpdater.js ├── lib │ ├── __tests__ │ │ └── loadThemes.spec.js │ ├── config.js │ ├── initPlugin.js │ ├── initializePlugins.js │ ├── plugins │ │ ├── index.js │ │ ├── npm.js │ │ └── settings │ │ │ ├── __tests__ │ │ │ ├── get.spec.js │ │ │ └── validate.spec.js │ │ │ ├── get.js │ │ │ ├── index.js │ │ │ └── validate.js │ ├── rpc.js │ └── themes.ts ├── main.development.js ├── main │ ├── actions │ │ ├── __tests__ │ │ │ ├── search.spec.js │ │ │ └── statusBar.spec.js │ │ ├── search.js │ │ └── statusBar.ts │ ├── components │ │ ├── Cerebro │ │ │ ├── index.js │ │ │ └── styles.module.css │ │ ├── ResultsList │ │ │ ├── Row │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── index.js │ │ │ └── styles.module.css │ │ ├── SmartIcon │ │ │ ├── getFileIcon │ │ │ │ ├── index.ts │ │ │ │ ├── mac.ts │ │ │ │ └── windows.ts │ │ │ └── index.tsx │ │ └── StatusBar │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ ├── constants │ │ ├── actionTypes.ts │ │ └── ui.ts │ ├── createWindow.js │ ├── createWindow │ │ ├── AppTray.js │ │ ├── autoStart.js │ │ ├── buildMenu.js │ │ ├── checkForUpdates.js │ │ ├── handleUrl.js │ │ ├── showWindowWithTerm.ts │ │ └── toggleWindow.ts │ ├── css │ │ ├── global.css │ │ ├── system-font.css │ │ └── themes │ │ │ ├── dark.css │ │ │ └── light.css │ ├── index.html │ ├── main.js │ ├── reducers │ │ ├── index.js │ │ ├── search.js │ │ └── statusBar.js │ └── store │ │ ├── configureStore.js │ │ └── index.ts ├── package.json ├── plugins │ ├── core │ │ ├── autocomplete │ │ │ └── index.js │ │ ├── icon.png │ │ ├── index.ts │ │ ├── plugins │ │ │ ├── Preview │ │ │ │ ├── ActionButton.js │ │ │ │ ├── FormItem.js │ │ │ │ ├── Settings.js │ │ │ │ ├── index.js │ │ │ │ └── styles.module.css │ │ │ ├── StatusBar │ │ │ │ ├── index.js │ │ │ │ └── styles.module.css │ │ │ ├── blacklist.js │ │ │ ├── format.js │ │ │ ├── getAvailablePlugins.js │ │ │ ├── getDebuggingPlugins.js │ │ │ ├── getInstalledPlugins.js │ │ │ ├── getReadme.js │ │ │ ├── index.js │ │ │ ├── initializeAsync.js │ │ │ └── loadPlugins.js │ │ ├── quit │ │ │ └── index.js │ │ ├── reload │ │ │ └── index.js │ │ ├── settings │ │ │ ├── Settings │ │ │ │ ├── Hotkey.js │ │ │ │ ├── countries.js │ │ │ │ ├── index.js │ │ │ │ └── styles.module.css │ │ │ └── index.js │ │ └── version │ │ │ └── index.js │ ├── externalPlugins.js │ └── index.ts ├── tray_icon.ico ├── tray_icon.png ├── tray_iconTemplate@2x.png └── yarn.lock ├── babel.config.js ├── build ├── icon.icns ├── icon.ico ├── icon.png ├── icons │ ├── 1024x1024.png │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ └── 512x512.png ├── installer.nsh └── logo.png ├── electron-builder.json ├── jest.config.js ├── package.json ├── postcss.config.js ├── server.js ├── tsconfig.json ├── webpack.config.base.js ├── webpack.config.development.js ├── webpack.config.electron.js ├── webpack.config.production.js └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{json,js,jsx,html,css}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [.eslintrc] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [.travis.yml] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=4000 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "jest": true 8 | }, 9 | "rules": { 10 | "consistent-return": 0, 11 | "comma-dangle": 0, 12 | "no-use-before-define": 0, 13 | "no-console": 0, 14 | "semi": ["error", "never"], 15 | "no-confusing-arrow": ["off"], 16 | "no-useless-escape": 0, 17 | "no-mixed-operators": "off", 18 | "no-continue": "off", 19 | "no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }], 20 | "import/no-extraneous-dependencies": "off", 21 | "import/imports-first": "off", 22 | "import/extensions": "off", 23 | "react/jsx-no-bind": 0, 24 | "react/prefer-stateless-function": 0, 25 | "react/no-string-refs": "off", 26 | "react/forbid-prop-types": "off", 27 | "react/no-unused-prop-types": "off", 28 | "react/no-danger": "off", 29 | "react/require-default-props": "off", 30 | "react/jsx-filename-extension": "off", 31 | "react/no-unescaped-entities": "off", 32 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 33 | "prefer-spread": "off", 34 | "class-methods-use-this": "off", 35 | "jsx-a11y/no-static-element-interactions": "off", 36 | "jsx-a11y/label-has-for": "off", 37 | "linebreak-style": 0 38 | }, 39 | "plugins": [ 40 | "jsx-a11y", 41 | "import", 42 | "react", 43 | "jest" 44 | ], 45 | "settings": { 46 | "import/core-modules": "electron", 47 | "import/resolver": { 48 | "node": { 49 | "paths": ["app"] 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | - [ ] I am on the [latest](https://github.com/cerebroapp/cerebro/releases/latest) Cerebro.app version 11 | - [ ] I have searched the [issues](https://github.com/cerebroapp/cerebro/issues) of this repo and believe that this is not a duplicate 12 | 13 | 22 | 23 | - **OS version and name**: 24 | - **Cerebro.app version**: 25 | - **Relevant information from devtools** _(See above how to open it)_: 26 | 27 | ## Issue 28 | 29 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/.github/pull_request_template.md -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js 16 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | - run: yarn 18 | - run: yarn test --detectOpenHandles --forceExit 19 | 20 | release: 21 | needs: test 22 | runs-on: ${{ matrix.os }} 23 | 24 | strategy: 25 | matrix: 26 | os: [macos-latest, ubuntu-latest, windows-latest] 27 | 28 | steps: 29 | - name: Check out Git repository 30 | uses: actions/checkout@v3 31 | 32 | - name: Install Node.js, NPM and Yarn 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: 16 36 | 37 | - name: Build & Release Electron app 38 | uses: samuelmeuli/action-electron-builder@v1 39 | with: 40 | github_token: ${{ secrets.github_token }} 41 | release: true 42 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js 16 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | - run: yarn 19 | - run: yarn test --detectOpenHandles --forceExit 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Tern-js work files 20 | .tern-port 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | 32 | # Folder view configuration files 33 | .DS_Store 34 | Desktop.ini 35 | 36 | # Thumbnail cache files 37 | ._* 38 | Thumbs.db 39 | 40 | # App packaged 41 | dist 42 | release 43 | /app/main.js 44 | /app/main.js.map 45 | 46 | .tmp/ 47 | 48 | # IDEs 49 | .idea 50 | 51 | *.sublime-project 52 | *.sublime-workspace 53 | 54 | .env 55 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | ".git": true, 4 | ".eslintcache": true, 5 | "app/dist": true, 6 | "app/main.prod.js": true, 7 | "app/main.prod.js.map": true, 8 | "dll": true, 9 | "release": true, 10 | "node_modules": true, 11 | "npm-debug.log.*": true, 12 | "test/**/__snapshots__": true, 13 | "yarn.lock": true, 14 | ".tmp": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Alexandr Subbotin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cerebro 2 | 3 | > Cerebro is an open-source launcher to improve your productivity and efficiency 4 | 5 | 6 | 7 | ## Usage 8 | 9 | You can download the latest version on the [releases](https://github.com/cerebroapp/cerebro/releases) page. 10 | 11 | - If there isn't an installer for your OS, check [build instructions](#build-executable-from-source). 12 | - If you are a linux user see [how to install the executable](#install-executable-on-linux) 13 | 14 | After the installation, use the default shortcut, `ctrl+space`, to show the app window. You can customize this shortcut by clicking on the icon in the menu bar, and then selecting "Preferences...". 15 | 16 | ![Cerebro](https://cloud.githubusercontent.com/assets/594298/20180624/858a483a-a75b-11e6-94a1-ef1edc4d95c3.gif) 17 | 18 | ### Plugins 19 | 20 | ### Core plugins 21 | 22 | - Search the web with your favourite search engine 23 | - Search & launch application, i.e. `spotify` 24 | - Navigate the file system with file previews (i.e. `~/Dropbox/passport.pdf`) 25 | - Calculator 26 | - Smart converter. `15$`, `150 рублей в евро`, `100 eur in gbp`; 27 | 28 | ### Install plugins 29 | 30 | You can manage and install more plugins by typing `plugins ` in the Cerebro search bar. 31 | 32 | Discover plugins and more at [Cerebro's Awesome List](https://github.com/lubien/awesome-cerebro). 33 | 34 | > If you're interested in creating your own plugin, check the [plugins documentation](https://github.com/cerebroapp/create-cerebro-plugin). 35 | 36 | ## Shortcuts 37 | 38 | Cerebro provides several shortcuts to improve your productivity: 39 | 40 | - `ctrl+c`: copy the result from a plugin to the clipboard, if the plugin does not provida a result, the term you introduced will be copied 41 | - `ctrl+1...9`: select directly a result from the list 42 | - `ctrl+[hjkl]`: navigate through the results using vim-like keys (Also `ctrl+o` to select the result) 43 | 44 | ### Change Theme 45 | 46 | Use the shortcut `ctrl+space` to open the app window, and type `Cerebro Settings`. There you will be able to change the Theme. 47 | 48 | > Currently Light and Dark Themes are supported out of the box 49 | 50 | ![change-cerebro-theme](https://user-images.githubusercontent.com/24854406/56137765-5880ca00-5fb7-11e9-86d0-e740de1127c2.gif) 51 | 52 | ### Config file path 53 | 54 | You can find the config file in the following path depending on your OS: 55 | 56 | *Windows*: `%APPDATA%/Cerebro/config.json` 57 | 58 | *Linux*: `$XDG_CONFIG_HOME/Cerebro/config.json` or `~/.config/Cerebro/config.json` 59 | 60 | *macOS*: `~/Library/Application Support/Cerebro/config.json` 61 | 62 | > ⚠️ A bad configuration file can break Cerebro. If you're not sure what you're doing, don't edit the config file directly. 63 | 64 | ## Build executable from source 65 | 66 | If you'd like to install a version of Cerebro, but the executable hasn't been released, you can follow these instructions to build it from source: 67 | 68 | 1. Clone the repository 69 | 2. Install dependencies with [yarn](https://yarnpkg.com/getting-started/install): 70 | 71 | ```bash 72 | yarn --force 73 | ``` 74 | 75 | 3. Build the package: 76 | 77 | ```bash 78 | yarn package 79 | ``` 80 | 81 | > Note: in CI we use `yarn build` as there is an action to package and publish the executables 82 | 83 | ## Install executable on Linux 84 | 85 | If you're a linux user, you might need to grant execution permissions to the executable. To do so, open the terminal and run the following command: 86 | 87 | ```bash 88 | sudo chmod +x 89 | ``` 90 | 91 | Then, you can install the executable by running the following command: 92 | 93 | - If you're using the AppImage executable: 94 | 95 | ```bash 96 | ./ 97 | ``` 98 | 99 | - If you're using the deb executable: 100 | 101 | ```bash 102 | dpkg -i 103 | ``` 104 | 105 | > On some computers you might need run these commands with elevated privileges (sudo). `sudo ./` or `sudo dpkg -i ` 106 | 107 | ## Contributing 108 | 109 | 110 | CerebroApp is an open source project and we welcome contributions from the community. 111 | In this document you will find information about how Cerebro works and how to contribute to the project. 112 | 113 | > ⚠️ NOTE: This document is for Cerebro developers. If you are looking for how to develop a plugin please check [plugin developers documentation](https://github.com/cerebroapp/create-cerebro-plugin). 114 | 115 | ### General architecture 116 | 117 | Cerebro is based on [Electron](https://electronjs.org/) and [React](https://reactjs.org/). 118 | 119 | A basic Electron app is composed of a *main process* and a *renderer process*. The main process is responsible for the app lifecycle, the renderer process is responsible for the UI. 120 | 121 | In our case we use: 122 | 123 | - [`app/main.development.js`](/app/main.development.js) as the main process 124 | - [`app/main/main.js`](/app/main/main.js) as the main renderer process 125 | - [`app/background/background.js`](/app/background/background.js) as a secondary renderer process 126 | 127 | All this files are bundled and transpiled with [Webpack](https://webpack.js.org/) and [Babel](https://babeljs.io/). 128 | 129 | The build process is managed by [electron-builder](https://www.electron.build/). 130 | 131 | ### Two renderer processes 132 | 133 | This two-renderer process architecture is used to keep the main renderer process (Cerebro) responsive and to avoid blocking the UI when executing long tasks. 134 | 135 | When we need to execute a long task we send a message to the background process, which executes the task asynchronously and sends a message back to the main renderer when the task is completed. 136 | 137 | This is the way we implement the plugins system. Their initializeAsync method is executed in the background process. 138 | 139 | ### Prerequisites 140 | 141 | - [Node.js](https://nodejs.org/en/) (>= 16) 142 | - [yarn](https://classic.yarnpkg.com/en/) 143 | 144 | ### Install Cerebro 145 | 146 | First, clone the repo via git: 147 | 148 | ```bash 149 | git clone https://github.com/cerebroapp/cerebro.git cerebro 150 | ``` 151 | 152 | Open the project 153 | 154 | ```bash 155 | cd cerebro 156 | ``` 157 | 158 | And then install dependencies: 159 | 160 | ```bash 161 | yarn 162 | ``` 163 | 164 | ### Run in development mode 165 | 166 | ```bash 167 | yarn run dev 168 | ``` 169 | 170 | > Note: requires a node version >=16.x 171 | 172 | ### Resolve common issues 173 | 174 | 1. `AssertionError: Current node version is not supported for development` on npm postinstall. 175 | After `yarn` postinstall script checks node version. If you see this error you have to check node and npm version in `package.json` `devEngines` section and install proper ones. 176 | 177 | 2. `Uncaught Error: Module version mismatch. Exepcted 50, got ...` 178 | This error means that node modules with native extensions build with wrong node version (your local node version != node version, included to electron). To fix this issue run `yarn --force` 179 | 180 | ### Conventional Commit Format 181 | 182 | The project is using conventional commit specification to keep track of changes. This helps us with the realeases and enforces a consistent style. 183 | You can commit as usually following this style or use the following commands that will help you to commit with the right style: 184 | 185 | - `yarn cz` 186 | - `yarn commit` 187 | 188 | ### Publish a release 189 | 190 | CerebroApp is using GH actions to build the app and publish it to a release. To publish a new release follow the steps below: 191 | 192 | 1. Update the version on both `package.json` and `app/package.json` files. 193 | 2. Create a release with from GH and publish it. 🚧 The release **tag** MUST contain the `v` prefix (❌ `0.1.2` → ✅`v0.1.2`). 194 | 3. Complete the name with a name and a description of the release. 195 | 4. The GH action is triggered and the release is updated when executables are built. 196 | ## License 197 | 198 | MIT © [Cerebro App](https://github.com/cerebroapp/cerebro/blob/master/LICENSE) 199 | -------------------------------------------------------------------------------- /__mocks__/@electron/remote.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | app: { 3 | getPath: () => '', 4 | getLocale: () => '', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /__mocks__/electron-store.js: -------------------------------------------------------------------------------- 1 | class Store { 2 | get() { 3 | return {} 4 | } 5 | 6 | set() {} 7 | } 8 | 9 | module.exports = Store 10 | -------------------------------------------------------------------------------- /__mocks__/electron.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | app: { 3 | getPath: jest.fn(), 4 | getLocale: jest.fn(), 5 | }, 6 | ipcRenderer: { 7 | on: jest.fn(), 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = '' 2 | -------------------------------------------------------------------------------- /__mocks__/plugins.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'test-plugin': { 3 | fn: () => {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/background/background.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import plugins from 'plugins' 4 | import { on, send } from 'lib/rpc' 5 | import { settings as pluginSettings, modulesDirectory } from 'lib/plugins' 6 | 7 | global.React = React 8 | global.ReactDOM = ReactDOM 9 | 10 | on('initializePluginAsync', ({ name }) => { 11 | const { allPlugins } = plugins 12 | console.group(`Initialize async plugin ${name}`) 13 | 14 | try { 15 | const plugin = allPlugins[name] || window.require(`${modulesDirectory}/${name}`) 16 | const { initializeAsync } = plugin 17 | 18 | if (!initializeAsync) { 19 | console.log('no `initializeAsync` function, skipped') 20 | return 21 | } 22 | 23 | console.log('running `initializeAsync`') 24 | initializeAsync((data) => { 25 | console.log('Done! Sending data back to main window') 26 | // Send message back to main window with initialization result 27 | send('plugin.message', { name, data }) 28 | }, pluginSettings.getUserSettings(plugin, name)) 29 | } catch (err) { console.log('Failed', err) } 30 | 31 | console.groupEnd() 32 | }) 33 | -------------------------------------------------------------------------------- /app/background/createWindow.js: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | 3 | export default ({ src }) => { 4 | const backgroundWindow = new BrowserWindow({ 5 | show: false, 6 | webPreferences: { 7 | nodeIntegration: true, 8 | nodeIntegrationInSubFrames: false, 9 | enableRemoteModule: true, 10 | contextIsolation: false 11 | }, 12 | }) 13 | 14 | backgroundWindow.loadURL(src) 15 | return backgroundWindow 16 | } 17 | -------------------------------------------------------------------------------- /app/background/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/initAutoUpdater.js: -------------------------------------------------------------------------------- 1 | import { autoUpdater } from 'electron-updater' 2 | 3 | const event = 'update-downloaded' 4 | 5 | const TEN_SECONDS = 10 * 1000 6 | const ONE_HOUR = 60 * 60 * 1000 7 | 8 | export default (w) => { 9 | if (process.env.NODE_ENV === 'development' || process.platform === 'linux') { 10 | return 11 | } 12 | 13 | autoUpdater.on(event, (payload) => { 14 | w.webContents.send('message', { 15 | message: event, 16 | payload 17 | }) 18 | }) 19 | 20 | setTimeout(() => { 21 | autoUpdater.checkForUpdates() 22 | }, TEN_SECONDS) 23 | 24 | setInterval(() => { 25 | autoUpdater.checkForUpdates() 26 | }, ONE_HOUR) 27 | } 28 | -------------------------------------------------------------------------------- /app/lib/__tests__/loadThemes.spec.js: -------------------------------------------------------------------------------- 1 | import themes from '../themes' 2 | 3 | const productionThemes = [ 4 | { 5 | value: '../dist/main/css/themes/light.css', 6 | label: 'Light' 7 | }, 8 | { 9 | value: '../dist/main/css/themes/dark.css', 10 | label: 'Dark' 11 | } 12 | ] 13 | 14 | test('returns themes for production', () => { 15 | expect(themes).toEqual(productionThemes) 16 | }) 17 | -------------------------------------------------------------------------------- /app/lib/config.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import Store from 'electron-store' 3 | import themes from './themes' 4 | 5 | const schema = { 6 | locale: { type: 'string', default: 'en-US' }, 7 | lang: { type: 'string', default: 'en' }, 8 | country: { type: 'string', default: 'US' }, 9 | theme: { type: 'string', default: themes[0].value }, 10 | hotkey: { type: 'string', default: 'Control+Space' }, 11 | showInTray: { type: 'boolean', default: true }, 12 | firstStart: { type: 'boolean', default: true }, 13 | developerMode: { type: 'boolean', default: false }, 14 | cleanOnHide: { type: 'boolean', default: true }, 15 | selectOnShow: { type: 'boolean', default: false }, 16 | hideOnBlur: { type: 'boolean', default: true }, 17 | plugins: { type: 'object', default: {} }, 18 | isMigratedPlugins: { type: 'boolean', default: false }, 19 | openAtLogin: { type: 'boolean', default: true }, 20 | winPosition: { type: 'array', default: [] }, 21 | searchBarPlaceholder: { type: 'string', default: 'Cerebro Search' }, 22 | } 23 | 24 | const store = new Store({ 25 | schema, 26 | migrations: { 27 | '>=0.9.0': (oldStore) => { 28 | oldStore.delete('positions') 29 | }, 30 | '>=0.10.0': (oldStore) => { 31 | oldStore.delete('crashreportingEnabled') 32 | } 33 | } 34 | }) 35 | 36 | /** 37 | * Get a value from global configuration 38 | * @param {String} key 39 | * @return {Any} 40 | */ 41 | const get = (key) => store.get(key) 42 | 43 | /** 44 | * Write a value to global config. It immedately rewrites global config 45 | * and notifies all listeners about changes 46 | * 47 | * @param {String} key 48 | * @param {Any} value 49 | */ 50 | const set = (key, value) => { 51 | store.set(key, value) 52 | if (ipcRenderer) { 53 | console.log('notify main process', key, value) 54 | // Notify main process about settings changes 55 | ipcRenderer.send('updateSettings', key, value) 56 | } 57 | } 58 | 59 | export default { get, set } 60 | -------------------------------------------------------------------------------- /app/lib/initPlugin.js: -------------------------------------------------------------------------------- 1 | import { send } from 'lib/rpc' 2 | import { settings as pluginSettings } from 'lib/plugins' 3 | 4 | /** 5 | * Initialices plugin sync and/or async by calling the `initialize` and `initializeAsync` functions 6 | * @param {Object} plugin A plugin object 7 | * @param {string} name The name entry in the plugin package.json 8 | */ 9 | const initPlugin = (plugin, name) => { 10 | const { initialize, initializeAsync } = plugin 11 | 12 | // Foreground plugin initialization 13 | if (initialize) { 14 | console.log('Initialize sync plugin', name) 15 | try { 16 | initialize(pluginSettings.getUserSettings(plugin, name)) 17 | } catch (e) { 18 | console.error(`Failed to initialize plugin: ${name}`, e) 19 | } 20 | } 21 | 22 | // Background plugin initialization 23 | if (initializeAsync) { 24 | console.log('Initialize async plugin', name) 25 | send('initializePluginAsync', { name }) 26 | } 27 | } 28 | 29 | export default initPlugin 30 | -------------------------------------------------------------------------------- /app/lib/initializePlugins.js: -------------------------------------------------------------------------------- 1 | import { on } from 'lib/rpc' 2 | import plugins from 'plugins' 3 | import initPlugin from './initPlugin' 4 | 5 | /** 6 | * Initialize all plugins and start listening for replies from plugin async initializers 7 | */ 8 | const initializePlugins = () => { 9 | const { allPlugins } = plugins 10 | Object.keys(allPlugins).forEach((name) => initPlugin(allPlugins[name], name)) 11 | 12 | // Start listening for replies from plugin async initializers 13 | on('plugin.message', ({ name, data }) => { 14 | const plugin = allPlugins[name] 15 | if (plugin && plugin.onMessage) plugin.onMessage(data) 16 | }) 17 | } 18 | 19 | export default initializePlugins 20 | -------------------------------------------------------------------------------- /app/lib/plugins/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import npm from './npm' 4 | 5 | const ensureFile = (src, content = '') => { 6 | if (!fs.existsSync(src)) { 7 | fs.writeFileSync(src, content) 8 | } 9 | } 10 | 11 | const ensureDir = (src) => { 12 | if (!fs.existsSync(src)) { 13 | fs.mkdirSync(src) 14 | } 15 | } 16 | 17 | const EMPTY_PACKAGE_JSON = JSON.stringify({ 18 | name: 'cerebro-plugins', 19 | dependencies: {} 20 | }, null, 2) 21 | 22 | export const pluginsPath = path.join(process.env.CEREBRO_DATA_PATH, 'plugins') 23 | export const modulesDirectory = path.join(pluginsPath, 'node_modules') 24 | export const packageJsonPath = path.join(pluginsPath, 'package.json') 25 | 26 | export const ensureFiles = () => { 27 | ensureDir(pluginsPath) 28 | ensureDir(modulesDirectory) 29 | ensureFile(packageJsonPath, EMPTY_PACKAGE_JSON) 30 | } 31 | 32 | export const client = npm(pluginsPath) 33 | export { default as settings } from './settings' 34 | -------------------------------------------------------------------------------- /app/lib/plugins/npm.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import os from 'os' 3 | import path from 'path' 4 | import tar from 'tar-fs' 5 | import zlib from 'zlib' 6 | import https from 'https' 7 | import { move, remove } from 'fs-extra' 8 | 9 | /** 10 | * Base url of npm API 11 | * 12 | * @type {String} 13 | */ 14 | const API_BASE = 'http://registry.npmjs.org/' 15 | 16 | /** 17 | * Format name of file from package archive. 18 | * Just remove `./package`prefix from name 19 | * 20 | * @param {Object} header 21 | * @return {Object} 22 | */ 23 | const formatPackageFile = (header) => ({ 24 | ...header, 25 | name: header.name.replace(/^package\//, '') 26 | }) 27 | 28 | const installPackage = async (tarPath, destination, middleware) => { 29 | console.log(`Extract ${tarPath} to ${destination}`) 30 | 31 | const packageName = path.parse(destination).name 32 | const tempPath = path.join(os.tmpdir(), packageName) 33 | 34 | console.log(`Download and extract to temp path: ${tempPath}`) 35 | 36 | await new Promise((resolve, reject) => { 37 | https.get(tarPath, (stream) => { 38 | const result = stream 39 | .pipe(zlib.Unzip()) 40 | .pipe(tar.extract(tempPath, { 41 | map: formatPackageFile 42 | })) 43 | result.on('error', reject) 44 | result.on('finish', () => { 45 | middleware().then(resolve) 46 | }) 47 | }) 48 | }) 49 | 50 | console.log(`Move ${tempPath} to ${destination}`) 51 | // Move temp folder to real location 52 | await move(tempPath, destination, { overwrite: true }) 53 | } 54 | 55 | /** 56 | * Lightweight npm client. 57 | * It only can install/uninstall package, without resolving dependencies 58 | * 59 | * @param {String} path Path to npm package directory 60 | * @return {Object} 61 | */ 62 | export default (dir) => { 63 | const packageJson = path.join(dir, 'package.json') 64 | const setConfig = (config) => ( 65 | fs.writeFileSync(packageJson, JSON.stringify(config, null, 2)) 66 | ) 67 | const getConfig = () => JSON.parse(fs.readFileSync(packageJson)) 68 | return { 69 | /** 70 | * Install npm package 71 | * @param {String} name Name of package in npm registry 72 | * 73 | * @param {Object} options 74 | * version {String} Version of npm package. Default is latest version 75 | * middleware {Function} 76 | * Function that returns promise. Called when package's archive is extracted 77 | * to temp folder, but before moving to real location 78 | * @return {Promise} 79 | */ 80 | async install(name, options = {}) { 81 | let versionToInstall 82 | const version = options.version || null 83 | const middleware = options.middleware || (() => Promise.resolve()) 84 | 85 | console.group('[npm] Install package', name) 86 | 87 | try { 88 | const resJson = await fetch(`${API_BASE}${name}`).then((response) => response.json()) 89 | 90 | versionToInstall = version || resJson['dist-tags'].latest 91 | console.log('Version:', versionToInstall) 92 | await installPackage( 93 | resJson.versions[versionToInstall].dist.tarball, 94 | path.join(dir, 'node_modules', name), 95 | middleware 96 | ) 97 | 98 | const json = getConfig() 99 | json.dependencies[name] = versionToInstall 100 | console.log('Add package to dependencies') 101 | setConfig(json) 102 | console.log('Finished installing', name) 103 | console.groupEnd() 104 | } catch (err) { 105 | console.log('Error in package installation') 106 | console.log(err) 107 | console.groupEnd() 108 | } 109 | }, 110 | 111 | update(name) { 112 | // Plugin update is downloading `.tar` and unarchiving it to temp folder 113 | // Only if this part was succeeded, current version of plugin is uninstalled 114 | // and temp folder moved to real plugin location 115 | const middleware = () => this.uninstall(name) 116 | return this.install(name, { middleware }) 117 | }, 118 | 119 | /** 120 | * Uninstall npm package 121 | * 122 | * @param {String} name 123 | * @return {Promise} 124 | */ 125 | async uninstall(name) { 126 | const modulePath = path.join(dir, 'node_modules', name) 127 | console.group('[npm] Uninstall package', name) 128 | console.log('Remove package directory ', modulePath) 129 | try { 130 | await remove(modulePath) 131 | 132 | const json = getConfig() 133 | console.log('Update package.json') 134 | delete json.dependencies?.[name] 135 | 136 | console.log('Rewrite package.json') 137 | setConfig(json) 138 | 139 | console.groupEnd() 140 | return true 141 | } catch (err) { 142 | console.log('Error in package uninstallation') 143 | console.log(err) 144 | console.groupEnd() 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /app/lib/plugins/settings/__tests__/get.spec.js: -------------------------------------------------------------------------------- 1 | import getUserSettings from '../get' 2 | 3 | const plugin = { 4 | settings: { 5 | test_setting1: { 6 | type: 'string', 7 | defaultValue: 'test', 8 | }, 9 | test_setting2: { 10 | type: 'number', 11 | defaultValue: 1, 12 | }, 13 | } 14 | } 15 | 16 | describe('Test getUserSettings', () => { 17 | it('returns valid settings object', () => { 18 | expect(getUserSettings(plugin, 'test-plugin')) 19 | .toEqual({ test_setting1: 'test', test_setting2: 1 }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /app/lib/plugins/settings/__tests__/validate.spec.js: -------------------------------------------------------------------------------- 1 | import validate from '../validate' 2 | 3 | const validSettings = { 4 | option1: { 5 | description: 'Just a test description', 6 | type: 'option', 7 | options: ['option_1', 'option_2'], 8 | }, 9 | option2: { 10 | description: 'Just a test description', 11 | type: 'number', 12 | defaultValue: 0 13 | }, 14 | option3: { 15 | description: 'Just a test description', 16 | type: 'number', 17 | defaultValue: 0 18 | }, 19 | option4: { 20 | description: 'Just a test description', 21 | type: 'bool' 22 | }, 23 | option5: { 24 | description: 'Just a test description', 25 | type: 'string', 26 | defaultValue: 'test' 27 | } 28 | } 29 | 30 | const invalidSettingsNoOptionsProvided = { 31 | option1: { 32 | description: 'Just a test description', 33 | type: 'option', 34 | options: [], 35 | } 36 | } 37 | 38 | const invalidSettingsInvalidType = { 39 | option1: { 40 | description: 'Just a test description', 41 | type: 'test' 42 | } 43 | } 44 | 45 | describe('Validate settings function', () => { 46 | it('returns true when plugin has no settings field', () => { 47 | const plugin = { 48 | fn: () => {} 49 | } 50 | expect(validate(plugin)).toEqual(true) 51 | }) 52 | 53 | it('returns true when plugin has empty settings field', () => { 54 | const plugin = { 55 | fn: () => {}, 56 | settings: {} 57 | } 58 | expect(validate(plugin)).toEqual(true) 59 | }) 60 | 61 | it('returns true when plugin has valid settings', () => { 62 | const plugin = { 63 | fn: () => {}, 64 | settings: validSettings 65 | } 66 | expect(validate(plugin)).toEqual(true) 67 | }) 68 | 69 | it('returns false when option type is options and no options provided', () => { 70 | const plugin = { 71 | fn: () => {}, 72 | settings: invalidSettingsNoOptionsProvided 73 | } 74 | expect(validate(plugin)).toEqual(false) 75 | }) 76 | 77 | it('returns false when option type is incorrect', () => { 78 | const plugin = { 79 | fn: () => {}, 80 | settings: invalidSettingsInvalidType 81 | } 82 | expect(validate(plugin)).toEqual(false) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /app/lib/plugins/settings/get.js: -------------------------------------------------------------------------------- 1 | import config from 'lib/config' 2 | 3 | /** 4 | * Returns the settings established by the user and previously saved in the config file 5 | * @param {string} pluginName The name entry of the plugin package.json 6 | * @returns An object with keys and values of the **stored** plugin settings 7 | */ 8 | const getExistingSettings = (pluginName) => config.get('plugins')[pluginName] || {} 9 | 10 | /** 11 | * Returns the sum of the default settings and the user settings 12 | * We use packageJsonName to avoid conflicts with plugins that export 13 | * a different name from the bundle. Two plugins can export the same name 14 | * but can't have the same package.json name 15 | * @param {Object} plugin 16 | * @param {string} packageJsonName 17 | * @returns An object with keys and values of the plugin settings 18 | */ 19 | const getUserSettings = (plugin, packageJsonName) => { 20 | const userSettings = {} 21 | const existingSettings = getExistingSettings(packageJsonName) 22 | const { settings: pluginSettings } = plugin 23 | 24 | if (pluginSettings) { 25 | // Provide default values if nothing is set by user 26 | Object.keys(pluginSettings).forEach((key) => { 27 | userSettings[key] = existingSettings[key] || pluginSettings[key].defaultValue 28 | }) 29 | } 30 | 31 | return userSettings 32 | } 33 | 34 | export default getUserSettings 35 | -------------------------------------------------------------------------------- /app/lib/plugins/settings/index.js: -------------------------------------------------------------------------------- 1 | import getUserSettings from './get' 2 | import validate from './validate' 3 | 4 | export default { getUserSettings, validate } 5 | -------------------------------------------------------------------------------- /app/lib/plugins/settings/validate.js: -------------------------------------------------------------------------------- 1 | import { every } from 'lodash/fp' 2 | 3 | const VALID_TYPES = new Set([ 4 | 'string', 5 | 'number', 6 | 'bool', 7 | 'option', 8 | ]) 9 | 10 | const validSetting = ({ type, options }) => { 11 | // General validation of settings 12 | if (!type || !VALID_TYPES.has(type)) return false 13 | 14 | // Type-specific validations 15 | if (type === 'option') return Array.isArray(options) && options.length 16 | 17 | return true 18 | } 19 | 20 | export default ({ settings }) => { 21 | if (!settings) return true 22 | return every(validSetting)(settings) 23 | } 24 | -------------------------------------------------------------------------------- /app/lib/rpc.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import EventEmitter from 'events' 3 | 4 | const emitter = new EventEmitter() 5 | 6 | /** 7 | * Channel name that is managed by main process. 8 | * @type {String} 9 | */ 10 | const CHANNEL = 'message' 11 | 12 | // Start listening for rpc channel 13 | ipcRenderer.on(CHANNEL, (_, { message, payload }) => { 14 | console.log(`[rpc] emit ${message}`) 15 | emitter.emit(message, payload) 16 | }) 17 | 18 | /** 19 | * Send message to rpc-channel 20 | * @param {String} message 21 | * @param {any} payload 22 | */ 23 | export const send = (message, payload) => { 24 | console.log(`[rpc] send ${message}`) 25 | ipcRenderer.send(CHANNEL, { message, payload }) 26 | } 27 | 28 | export const on = emitter.on.bind(emitter) 29 | export const off = emitter.removeListener.bind(emitter) 30 | export const once = emitter.once.bind(emitter) 31 | -------------------------------------------------------------------------------- /app/lib/themes.ts: -------------------------------------------------------------------------------- 1 | const prefix = process.env.NODE_ENV === 'development' ? 'http://localhost:3000/' : '../' 2 | 3 | type Theme = { value: string, label: string} 4 | 5 | const themes: Array = [ 6 | { 7 | value: `${prefix}dist/main/css/themes/light.css`, 8 | label: 'Light' 9 | }, 10 | { 11 | value: `${prefix}dist/main/css/themes/dark.css`, 12 | label: 'Dark' 13 | } 14 | ] 15 | 16 | export default themes 17 | -------------------------------------------------------------------------------- /app/main.development.js: -------------------------------------------------------------------------------- 1 | import { app, ipcMain } from 'electron' 2 | import path from 'path' 3 | 4 | import createMainWindow from './main/createWindow' 5 | import createBackgroundWindow from './background/createWindow' 6 | import config from './lib/config' 7 | import AppTray from './main/createWindow/AppTray' 8 | import autoStart from './main/createWindow/autoStart' 9 | import initAutoUpdater from './initAutoUpdater' 10 | 11 | import { 12 | WINDOW_WIDTH, 13 | } from 'main/constants/ui' 14 | 15 | const iconSrc = { 16 | DEFAULT: `${__dirname}/tray_icon.png`, 17 | darwin: `${__dirname}/tray_iconTemplate@2x.png`, 18 | win32: `${__dirname}/tray_icon.ico` 19 | } 20 | 21 | const trayIconSrc = iconSrc[process.platform] || iconSrc.DEFAULT 22 | 23 | const isDev = () => (process.env.NODE_ENV === 'development' || config.get('developerMode')) 24 | 25 | let mainWindow 26 | let backgroundWindow 27 | let tray 28 | 29 | const setupEnvVariables = () => { 30 | process.env.CEREBRO_VERSION = app.getVersion() 31 | 32 | const isPortableMode = process.argv.some((arg) => arg.toLowerCase() === '-p' || arg.toLowerCase() === '--portable') 33 | // initiate portable mode 34 | // set data directory to ./userdata 35 | if (isPortableMode) { 36 | const userDataPath = path.join(process.cwd(), 'userdata') 37 | app.setPath('userData', userDataPath) 38 | process.env.CEREBRO_DATA_PATH = userDataPath 39 | } else { 40 | process.env.CEREBRO_DATA_PATH = app.getPath('userData') 41 | } 42 | } 43 | 44 | app.whenReady().then(() => { 45 | // We cannot require the screen module until the app is ready. 46 | const { screen } = require('electron') 47 | 48 | setupEnvVariables() 49 | 50 | mainWindow = createMainWindow({ 51 | isDev, 52 | src: `file://${__dirname}/main/index.html`, // Main window html 53 | }) 54 | 55 | mainWindow.on('show', (event) => { 56 | const cursorScreenPoint = screen.getCursorScreenPoint() 57 | const nearestDisplay = screen.getDisplayNearestPoint(cursorScreenPoint) 58 | 59 | const goalWidth = WINDOW_WIDTH 60 | const goalX = Math.floor(nearestDisplay.bounds.x + (nearestDisplay.size.width - goalWidth) / 2) 61 | const goalY = nearestDisplay.bounds.y + 200 // "top" is hardcoded now, should get from config or calculate accordingly? 62 | 63 | config.set('winPosition', [goalX, goalY]) 64 | }) 65 | 66 | // eslint-disable-next-line global-require 67 | require('@electron/remote/main').initialize() 68 | // eslint-disable-next-line global-require 69 | require('@electron/remote/main').enable(mainWindow.webContents) 70 | 71 | backgroundWindow = createBackgroundWindow({ 72 | src: `file://${__dirname}/background/index.html`, 73 | }) 74 | 75 | // eslint-disable-next-line global-require 76 | require('@electron/remote/main').enable(backgroundWindow.webContents) 77 | 78 | tray = new AppTray({ 79 | src: trayIconSrc, 80 | isDev: isDev(), 81 | mainWindow, 82 | backgroundWindow, 83 | }) 84 | 85 | // Show tray icon if it is set in configuration 86 | if (config.get('showInTray')) { tray.show() } 87 | 88 | autoStart.isEnabled().then((enabled) => { 89 | if (config.get('openAtLogin') !== enabled) { 90 | autoStart.set(config.get('openAtLogin')) 91 | } 92 | }) 93 | 94 | initAutoUpdater(mainWindow) 95 | 96 | app?.dock?.hide() 97 | }) 98 | 99 | ipcMain.on('message', (event, payload) => { 100 | const toWindow = event.sender === mainWindow.webContents ? backgroundWindow : mainWindow 101 | toWindow.webContents.send('message', payload) 102 | }) 103 | 104 | ipcMain.on('updateSettings', (event, key, value) => { 105 | mainWindow.settingsChanges.emit(key, value) 106 | 107 | // Show or hide menu bar icon when it is changed in setting 108 | if (key === 'showInTray') { 109 | value 110 | ? tray.show() 111 | : tray.hide() 112 | } 113 | 114 | // Show or hide "development" section in tray menu 115 | if (key === 'developerMode') { tray.setIsDev(isDev()) } 116 | 117 | // Enable or disable auto start 118 | if (key === 'openAtLogin') { 119 | autoStart.isEnabled().then((enabled) => { 120 | if (value !== enabled) autoStart.set(value) 121 | }) 122 | } 123 | }) 124 | 125 | ipcMain.on('quit', () => app.quit()) 126 | ipcMain.on('reload', () => { 127 | app.relaunch() 128 | app.exit() 129 | }) 130 | -------------------------------------------------------------------------------- /app/main/actions/__tests__/search.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { 6 | MOVE_CURSOR, 7 | SELECT_ELEMENT, 8 | UPDATE_RESULT, 9 | HIDE_RESULT, 10 | RESET, 11 | } from 'main/constants/actionTypes' 12 | 13 | import * as actions from '../search' 14 | 15 | describe('reset', () => { 16 | it('returns valid action', () => { 17 | expect(actions.reset()).toEqual({ 18 | type: RESET, 19 | }) 20 | }) 21 | }) 22 | 23 | describe('moveCursor', () => { 24 | it('returns valid action for +1', () => { 25 | expect(actions.moveCursor(1)).toEqual({ 26 | type: MOVE_CURSOR, 27 | payload: 1 28 | }) 29 | }) 30 | 31 | it('returns valid action for -1', () => { 32 | expect(actions.moveCursor(-1)).toEqual({ 33 | type: MOVE_CURSOR, 34 | payload: -1 35 | }) 36 | }) 37 | }) 38 | 39 | describe('selectElement', () => { 40 | it('returns valid action', () => { 41 | expect(actions.selectElement(15)).toEqual({ 42 | type: SELECT_ELEMENT, 43 | payload: 15 44 | }) 45 | }) 46 | }) 47 | 48 | describe('updateTerm', () => { 49 | describe('for empty term', () => { 50 | it('returns reset action', () => { 51 | expect(actions.updateTerm('')).toEqual({ 52 | type: RESET, 53 | }) 54 | }) 55 | }) 56 | }) 57 | 58 | describe('updateElement', () => { 59 | it('returns valid action', () => { 60 | const id = 1 61 | const result = { title: 'updated' } 62 | expect(actions.updateElement(id, result)).toEqual({ 63 | type: UPDATE_RESULT, 64 | payload: { id, result } 65 | }) 66 | }) 67 | }) 68 | 69 | describe('hideElement', () => { 70 | it('returns valid action', () => { 71 | const id = 1 72 | expect(actions.hideElement(id)).toEqual({ 73 | type: HIDE_RESULT, 74 | payload: { id } 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /app/main/actions/__tests__/statusBar.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { 6 | SET_STATUS_BAR_TEXT 7 | } from 'main/constants/actionTypes' 8 | 9 | import * as actions from '../statusBar' 10 | 11 | describe('reset', () => { 12 | it('returns valid action', () => { 13 | expect(actions.reset()).toEqual({ 14 | type: SET_STATUS_BAR_TEXT, 15 | payload: null 16 | }) 17 | }) 18 | }) 19 | 20 | describe('setValue', () => { 21 | it('returns valid action when value passed', () => { 22 | expect(actions.setValue('test value')).toEqual({ 23 | type: SET_STATUS_BAR_TEXT, 24 | payload: 'test value' 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /app/main/actions/search.js: -------------------------------------------------------------------------------- 1 | import plugins from 'plugins' 2 | import config from 'lib/config' 3 | import { shell, clipboard } from 'electron' 4 | 5 | import { settings as pluginSettings } from 'lib/plugins' 6 | import { 7 | UPDATE_TERM, 8 | MOVE_CURSOR, 9 | SELECT_ELEMENT, 10 | SHOW_RESULT, 11 | HIDE_RESULT, 12 | UPDATE_RESULT, 13 | RESET, 14 | CHANGE_VISIBLE_RESULTS, 15 | } from 'main/constants/actionTypes' 16 | 17 | import store from '../store' 18 | 19 | const remote = process.type === 'browser' 20 | ? undefined 21 | : require('@electron/remote') 22 | 23 | /** 24 | * Default scope object would be first argument for plugins 25 | * 26 | * @type {Object} 27 | */ 28 | const DEFAULT_SCOPE = { 29 | config, 30 | actions: { 31 | open: (q) => shell.openExternal(q), 32 | reveal: (q) => shell.showItemInFolder(q), 33 | copyToClipboard: (q) => clipboard.writeText(q), 34 | replaceTerm: (term) => store.dispatch(updateTerm(term)), 35 | hideWindow: () => remote.getCurrentWindow().hide() 36 | } 37 | } 38 | 39 | /** 40 | * Pass search term to all plugins and handle their results 41 | * @param {String} term Search term 42 | * @param {Function} display Callback function that receives used search term and found results 43 | */ 44 | const eachPlugin = (term, display) => { 45 | const { allPlugins } = plugins 46 | // TODO: order results by frequency? 47 | Object.keys(allPlugins).forEach((name) => { 48 | const plugin = allPlugins[name] 49 | try { 50 | plugin.fn({ 51 | ...DEFAULT_SCOPE, 52 | term, 53 | hide: (id) => store.dispatch(hideElement(`${name}-${id}`)), 54 | update: (id, result) => store.dispatch(updateElement(`${name}-${id}`, result)), 55 | display: (payload) => display(name, payload), 56 | settings: pluginSettings.getUserSettings(plugin, name) 57 | }) 58 | } catch (error) { 59 | // Do not fail on plugin errors, just log them to console 60 | console.log('Error running plugin', name, error) 61 | } 62 | }) 63 | } 64 | 65 | /** 66 | * Handle results found by plugin 67 | * 68 | * @param {String} term Search term that was used for found results 69 | * @param {Array | Object} result Found results (or result) 70 | * @return {Object} redux action 71 | */ 72 | function onResultFound(term, result) { 73 | return { 74 | type: SHOW_RESULT, 75 | payload: { 76 | result, 77 | term, 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Action that clears everthing in search box 84 | * 85 | * @return {Object} redux action 86 | */ 87 | export function reset() { 88 | return { type: RESET } 89 | } 90 | 91 | /** 92 | * Action that updates search term 93 | * 94 | * @param {String} term 95 | * @return {Object} redux action 96 | */ 97 | export function updateTerm(term) { 98 | if (term === '') return reset() 99 | 100 | return (dispatch) => { 101 | dispatch({ 102 | type: UPDATE_TERM, 103 | payload: term, 104 | }) 105 | eachPlugin(term, (plugin, payload) => { 106 | let result = Array.isArray(payload) ? payload : [payload] 107 | result = result.map((x) => ({ 108 | ...x, 109 | plugin, 110 | // Scope result ids with plugin name and use title if id is empty 111 | id: `${plugin}-${x.id || x.title}` 112 | })) 113 | if (result.length === 0) { 114 | // Do not dispatch for empty results 115 | return 116 | } 117 | dispatch(onResultFound(term, result)) 118 | }) 119 | } 120 | } 121 | 122 | /** 123 | * Action to move highlighted cursor to next or prev element 124 | * @param {1 | -1} diff 125 | * @return {Object} redux action 126 | */ 127 | export function moveCursor(diff) { 128 | return { 129 | type: MOVE_CURSOR, 130 | payload: diff 131 | } 132 | } 133 | 134 | /** 135 | * Action to change highlighted element 136 | * @param {number} index Index of new highlighted element 137 | * @return {Object} redux action 138 | */ 139 | export function selectElement(index) { 140 | return { 141 | type: SELECT_ELEMENT, 142 | payload: index 143 | } 144 | } 145 | 146 | /** 147 | * Action to remove element from results list by id 148 | * @param {String} id 149 | * @return {Object} redux action 150 | */ 151 | export function hideElement(id) { 152 | return { 153 | type: HIDE_RESULT, 154 | payload: { id } 155 | } 156 | } 157 | 158 | /** 159 | * Action to update displayed element with new result 160 | * @param {String} id 161 | * @return {Object} redux action 162 | */ 163 | export function updateElement(id, result) { 164 | return { 165 | type: UPDATE_RESULT, 166 | payload: { id, result } 167 | } 168 | } 169 | 170 | /** 171 | * Change count of visible results (without scroll) in list 172 | */ 173 | export function changeVisibleResults(count) { 174 | return { 175 | type: CHANGE_VISIBLE_RESULTS, 176 | payload: count, 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /app/main/actions/statusBar.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SET_STATUS_BAR_TEXT 3 | } from '../constants/actionTypes' 4 | 5 | export function reset(): { type: string, payload: null } { 6 | return { 7 | type: SET_STATUS_BAR_TEXT, 8 | payload: null 9 | } 10 | } 11 | 12 | export function setValue(text: string): { type: string, payload: string } { 13 | return { 14 | type: SET_STATUS_BAR_TEXT, 15 | payload: text 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/main/components/Cerebro/index.js: -------------------------------------------------------------------------------- 1 | /* eslint default-case: 0 */ 2 | 3 | import React, { 4 | useEffect, useRef, useState 5 | } from 'react' 6 | import PropTypes from 'prop-types' 7 | import { connect } from 'react-redux' 8 | import { bindActionCreators } from 'redux' 9 | import { clipboard } from 'electron' 10 | import { focusableSelector } from '@cerebroapp/cerebro-ui' 11 | import escapeStringRegexp from 'escape-string-regexp' 12 | 13 | import { 14 | WINDOW_WIDTH, 15 | INPUT_HEIGHT, 16 | RESULT_HEIGHT, 17 | MIN_VISIBLE_RESULTS, 18 | } from 'main/constants/ui' 19 | import * as searchActions from 'main/actions/search' 20 | 21 | import config from 'lib/config' 22 | import ResultsList from '../ResultsList' 23 | import StatusBar from '../StatusBar' 24 | import styles from './styles.module.css' 25 | 26 | const remote = require('@electron/remote') 27 | 28 | /** 29 | * Wrap click or mousedown event to custom `select-item` event, 30 | * that includes only information about clicked keys (alt, shift, ctrl and meta) 31 | * 32 | * @param {Event} realEvent 33 | * @return {CustomEvent} 34 | */ 35 | const wrapEvent = (realEvent) => { 36 | const event = new CustomEvent('select-item', { cancelable: true }) 37 | event.altKey = realEvent.altKey 38 | event.shiftKey = realEvent.shiftKey 39 | event.ctrlKey = realEvent.ctrlKey 40 | event.metaKey = realEvent.metaKey 41 | return event 42 | } 43 | 44 | /** 45 | * Set focus to first focusable element in preview 46 | */ 47 | const focusPreview = () => { 48 | const previewDom = document.getElementById('preview') 49 | const firstFocusable = previewDom.querySelector(focusableSelector) 50 | if (firstFocusable) { firstFocusable.focus() } 51 | } 52 | 53 | /** 54 | * Check if cursor in the end of input 55 | * 56 | * @param {DOMElement} input 57 | */ 58 | const cursorInEndOfInut = ({ selectionStart, selectionEnd, value }) => ( 59 | selectionStart === selectionEnd && selectionStart >= value.length 60 | ) 61 | 62 | const electronWindow = remote.getCurrentWindow() 63 | 64 | /** 65 | * Set resizable and size for main electron window when results count is changed 66 | */ 67 | const updateElectronWindow = (results, visibleResults) => { 68 | const { length } = results 69 | const win = electronWindow 70 | const [width] = win.getSize() 71 | 72 | // When results list is empty window is not resizable 73 | win.setResizable(length !== 0) 74 | 75 | if (length === 0) { 76 | win.setMinimumSize(WINDOW_WIDTH, INPUT_HEIGHT) 77 | win.setSize(width, INPUT_HEIGHT) 78 | const [x, y] = config.get('winPosition') 79 | win.setPosition(x, y) 80 | return 81 | } 82 | 83 | const resultHeight = Math.max(Math.min(visibleResults, length), MIN_VISIBLE_RESULTS) 84 | const heightWithResults = resultHeight * RESULT_HEIGHT + INPUT_HEIGHT 85 | const minHeightWithResults = MIN_VISIBLE_RESULTS * RESULT_HEIGHT + INPUT_HEIGHT 86 | win.setMinimumSize(WINDOW_WIDTH, minHeightWithResults) 87 | win.setSize(width, heightWithResults) 88 | const [x, y] = config.get('winPosition') 89 | win.setPosition(x, y) 90 | } 91 | 92 | const onDocumentKeydown = (event) => { 93 | if (event.keyCode === 27) { 94 | event.preventDefault() 95 | document.getElementById('main-input').focus() 96 | } 97 | } 98 | 99 | function Autocomplete({ autocompleteCalculator }) { 100 | const autocompleteTerm = autocompleteCalculator() 101 | 102 | return autocompleteTerm 103 | ?
{autocompleteTerm}
104 | : null 105 | } 106 | 107 | Autocomplete.propTypes = { 108 | autocompleteCalculator: PropTypes.func.isRequired, 109 | } 110 | 111 | /** 112 | * Main search container 113 | * 114 | * TODO: Remove redux 115 | * TODO: Split to more components 116 | */ 117 | function Cerebro({ 118 | results, selected, visibleResults, actions, term, prevTerm, statusBarText 119 | }) { 120 | const mainInput = useRef(null) 121 | const [mainInputFocused, setMainInputFocused] = useState(false) 122 | const [prevResultsLenght, setPrevResultsLenght] = useState(() => results.length) 123 | 124 | const focusMainInput = () => { 125 | mainInput.current.focus() 126 | if (config.get('selectOnShow')) { 127 | mainInput.current.select() 128 | } 129 | } 130 | 131 | // suscribe to events 132 | useEffect(() => { 133 | focusMainInput() 134 | updateElectronWindow(results, visibleResults) 135 | // Listen for window.resize and change default space for results to user's value 136 | window.addEventListener('resize', onWindowResize) 137 | // Add some global key handlers 138 | window.addEventListener('keydown', onDocumentKeydown) 139 | // Cleanup event listeners on unload 140 | // NOTE: when page refreshed (location.reload) componentWillUnmount is not called 141 | window.addEventListener('beforeunload', cleanup) 142 | electronWindow.on('show', focusMainInput) 143 | electronWindow.on('show', () => updateElectronWindow(results, visibleResults)) 144 | 145 | // function to be called when unmounted 146 | return () => { 147 | cleanup() 148 | } 149 | }, []) 150 | 151 | if (results.length !== prevResultsLenght) { 152 | // Resize electron window when results count changed 153 | updateElectronWindow(results, visibleResults) 154 | setPrevResultsLenght(results.length) 155 | } 156 | 157 | /** 158 | * Handle resize window and change count of visible results depends on window size 159 | */ 160 | const onWindowResize = () => { 161 | if (results.length <= MIN_VISIBLE_RESULTS) return false 162 | 163 | let maxVisibleResults = Math.floor((window.outerHeight - INPUT_HEIGHT) / RESULT_HEIGHT) 164 | maxVisibleResults = Math.max(MIN_VISIBLE_RESULTS, maxVisibleResults) 165 | if (maxVisibleResults !== visibleResults) { 166 | actions.changeVisibleResults(maxVisibleResults) 167 | } 168 | } 169 | 170 | /** 171 | * Handle keyboard shortcuts 172 | */ 173 | const onKeyDown = (event) => { 174 | const highlighted = highlightedResult() 175 | // TODO: go to first result on cmd+up and last result on cmd+down 176 | if (highlighted && highlighted.onKeyDown) highlighted.onKeyDown(event) 177 | 178 | if (event.defaultPrevented) { return } 179 | 180 | const keyActions = { 181 | select: () => selectCurrent(event), 182 | 183 | arrowRight: () => { 184 | if (cursorInEndOfInut(event.target)) { 185 | if (autocompleteValue()) { 186 | // Autocomplete by arrow right only if autocomple value is shown 187 | autocomplete(event) 188 | } else { 189 | focusPreview() 190 | event.preventDefault() 191 | } 192 | } 193 | }, 194 | 195 | arrowDown: () => { 196 | actions.moveCursor(1) 197 | event.preventDefault() 198 | }, 199 | 200 | arrowUp: () => { 201 | if (results.length > 0) { 202 | actions.moveCursor(-1) 203 | } else if (prevTerm) { 204 | actions.updateTerm(prevTerm) 205 | } 206 | event.preventDefault() 207 | } 208 | } 209 | 210 | // shortcuts for ctrl+... 211 | if ((event.metaKey || event.ctrlKey) && !event.altKey) { 212 | // Copy to clipboard on cmd+c 213 | if (event.keyCode === 67) { 214 | const text = highlightedResult()?.clipboard || term 215 | if (text) { 216 | clipboard.writeText(text) 217 | actions.reset() 218 | if (!event.defaultPrevented) { 219 | electronWindow.hide() 220 | } 221 | event.preventDefault() 222 | } 223 | return 224 | } 225 | 226 | // Select text on cmd+a 227 | if (event.keyCode === 65) { 228 | mainInput.current.select() 229 | event.preventDefault() 230 | } 231 | 232 | // Select element by number 233 | if (event.keyCode >= 49 && event.keyCode <= 57) { 234 | const number = Math.abs(49 - event.keyCode) 235 | const result = results[number] 236 | 237 | if (result) return selectItem(result, event) 238 | } 239 | 240 | // Lightweight vim-mode: cmd/ctrl + jklo 241 | switch (event.keyCode) { 242 | case 74: 243 | keyActions.arrowDown() 244 | break 245 | case 75: 246 | keyActions.arrowUp() 247 | break 248 | case 76: 249 | keyActions.arrowRight() 250 | break 251 | case 79: 252 | keyActions.select() 253 | break 254 | } 255 | } 256 | 257 | switch (event.keyCode) { 258 | case 9: 259 | autocomplete(event) 260 | break 261 | case 39: 262 | keyActions.arrowRight() 263 | break 264 | case 40: 265 | keyActions.arrowDown() 266 | break 267 | case 38: 268 | keyActions.arrowUp() 269 | break 270 | case 13: 271 | keyActions.select() 272 | break 273 | case 27: 274 | actions.reset() 275 | electronWindow.hide() 276 | break 277 | } 278 | } 279 | 280 | const onMainInputFocus = () => setMainInputFocused(true) 281 | const onMainInputBlur = () => setMainInputFocused(false) 282 | 283 | const cleanup = () => { 284 | window.removeEventListener('resize', onWindowResize) 285 | window.removeEventListener('keydown', onDocumentKeydown) 286 | window.removeEventListener('beforeunload', cleanup) 287 | electronWindow.removeAllListeners('show') 288 | } 289 | 290 | /** 291 | * Get highlighted result 292 | * @return {Object} 293 | */ 294 | const highlightedResult = () => results[selected] 295 | 296 | /** 297 | * Select item from results list 298 | * @param {[type]} item [description] 299 | * @return {[type]} [description] 300 | */ 301 | const selectItem = (item, realEvent) => { 302 | actions.reset() 303 | const event = wrapEvent(realEvent) 304 | item.onSelect(event) 305 | 306 | if (!event.defaultPrevented) electronWindow.hide() 307 | } 308 | 309 | /** 310 | * Autocomple search term from highlighted result 311 | */ 312 | const autocomplete = (event) => { 313 | const { term: highlightedTerm } = highlightedResult() 314 | if (highlightedTerm && highlightedTerm !== term) { 315 | actions.updateTerm(highlightedTerm) 316 | event.preventDefault() 317 | } 318 | } 319 | 320 | /** 321 | * Select highlighted element 322 | */ 323 | const selectCurrent = (event) => selectItem(highlightedResult(), event) 324 | 325 | const autocompleteValue = () => { 326 | const selectedResult = highlightedResult() 327 | if (selectedResult && selectedResult.term) { 328 | const regexp = new RegExp(`^${escapeStringRegexp(term)}`, 'i') 329 | if (selectedResult.term.match(regexp)) { 330 | return selectedResult.term.replace(regexp, term) 331 | } 332 | } 333 | return '' 334 | } 335 | 336 | return ( 337 |
338 | 339 |
340 | actions.updateTerm(e.target.value)} 348 | onKeyDown={onKeyDown} 349 | onFocus={onMainInputFocus} 350 | onBlur={onMainInputBlur} 351 | /> 352 |
353 | 361 | {statusBarText && } 362 |
363 | ) 364 | } 365 | 366 | Cerebro.propTypes = { 367 | actions: PropTypes.shape({ 368 | reset: PropTypes.func, 369 | moveCursor: PropTypes.func, 370 | updateTerm: PropTypes.func, 371 | changeVisibleResults: PropTypes.func, 372 | selectElement: PropTypes.func, 373 | }), 374 | results: PropTypes.array, 375 | selected: PropTypes.number, 376 | visibleResults: PropTypes.number, 377 | term: PropTypes.string, 378 | statusBarText: PropTypes.string, 379 | prevTerm: PropTypes.string, 380 | } 381 | 382 | function mapStateToProps(state) { 383 | return { 384 | selected: state.search.selected, 385 | results: state.search.resultIds.map((id) => state.search.resultsById[id]), 386 | term: state.search.term, 387 | statusBarText: state.statusBar.text, 388 | prevTerm: state.search.prevTerm, 389 | visibleResults: state.search.visibleResults, 390 | } 391 | } 392 | 393 | function mapDispatchToProps(dispatch) { 394 | return { 395 | actions: bindActionCreators(searchActions, dispatch), 396 | } 397 | } 398 | 399 | export default connect(mapStateToProps, mapDispatchToProps)(Cerebro) 400 | -------------------------------------------------------------------------------- /app/main/components/Cerebro/styles.module.css: -------------------------------------------------------------------------------- 1 | .search { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | } 7 | 8 | .inputWrapper { 9 | position: relative; 10 | z-index: 2; 11 | width: 100%; 12 | height: 45px; 13 | } 14 | 15 | .autocomplete { 16 | position: absolute; 17 | z-index: 1; 18 | width: 100%; 19 | height: 45px; 20 | font-size: 1.5em; 21 | padding: 0 10px; 22 | line-height: 46px; 23 | box-sizing: border-box; 24 | color: var(--secondary-font-color); 25 | white-space: pre; 26 | } 27 | 28 | ::-webkit-scrollbar { 29 | height: var(--scroll-height); 30 | width: var(--scroll-width); 31 | background: var(--scroll-background); 32 | -webkit-border-radius: 0; 33 | } 34 | 35 | ::-webkit-scrollbar-track { 36 | background: var(--scroll-track); 37 | } 38 | ::-webkit-scrollbar-track:active { 39 | background: var(--scroll-track-active); 40 | } 41 | 42 | ::-webkit-scrollbar-track:hover { 43 | background: var(--scroll-track-hover); 44 | } 45 | 46 | ::-webkit-scrollbar-thumb { 47 | background: var(--scroll-thumb); 48 | -webkit-border-radius: 3px; 49 | } 50 | 51 | ::-webkit-scrollbar-thumb:hover { 52 | background: var(--scroll-thumb-hover); 53 | } 54 | 55 | ::-webkit-scrollbar-thumb:active { 56 | background: var(--scroll-thumb-active); 57 | } 58 | 59 | ::-webkit-scrollbar-thumb:vertical { 60 | min-height: 10px; 61 | } 62 | 63 | ::-webkit-scrollbar-thumb:horizontal { 64 | min-width: 10px; 65 | } 66 | 67 | .input { 68 | width: 100%; 69 | height: 45px; 70 | color: var(--main-font-color); 71 | font-family: var(--main-font); 72 | font-size: 1.5em; 73 | border: 0; 74 | outline: none; 75 | padding: 0 10px; 76 | line-height: 60px; 77 | box-sizing: border-box; 78 | background: transparent; 79 | white-space: nowrap; 80 | -webkit-app-region: drag; 81 | -webkit-user-select: none; 82 | } 83 | -------------------------------------------------------------------------------- /app/main/components/ResultsList/Row/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SmartIcon from '../../SmartIcon' 3 | 4 | // @ts-ignore 5 | import styles from './styles.module.css' 6 | 7 | interface RowProps { 8 | style?: any 9 | title?: string 10 | icon?: string 11 | selected?: boolean 12 | subtitle?: string 13 | onSelect?: () => void 14 | onMouseMove?: () => void 15 | } 16 | 17 | function Row({ 18 | selected, icon, title, onSelect, onMouseMove, subtitle, style 19 | }: RowProps) { 20 | const classNames = [styles.row, selected ? styles.selected : null].join(' ') 21 | 22 | return ( 23 |
{}} 29 | > 30 | {icon && } 31 | 32 |
33 | {title &&
{title}
} 34 | 35 | {subtitle &&
{subtitle}
} 36 |
37 |
38 | ) 39 | } 40 | 41 | export default Row 42 | -------------------------------------------------------------------------------- /app/main/components/ResultsList/Row/styles.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO: colors should be moved to variables 3 | */ 4 | .row { 5 | position: relative; 6 | display: flex; 7 | flex-wrap: nowrap; 8 | flex-direction: row; 9 | align-items: flex-start; 10 | white-space: nowrap; 11 | width: 100%; 12 | cursor: pointer; 13 | box-sizing: border-box; 14 | height: 45px; 15 | padding: 3px 5px; 16 | align-items: center; 17 | color: var(--main-font-color); 18 | background: var(--result-background); 19 | } 20 | 21 | .icon { 22 | max-height: 30px; 23 | max-width: 30px; 24 | margin-right: 5px; 25 | } 26 | 27 | .title { 28 | font-size: .8em; 29 | max-width: 100%; 30 | /* overflow-x: hidden; */ 31 | color: var(--result-title-color); 32 | } 33 | 34 | 35 | .subtitle { 36 | font-size: 0.8em; 37 | font-weight: 300; 38 | color: var(--result-subtitle-color); 39 | max-width: 100%; 40 | /* overflow-x: hidden; */ 41 | } 42 | 43 | .selected { 44 | background: var(--selected-result-background); 45 | .title { 46 | color: var(--selected-result-title-color); 47 | } 48 | .subtitle { 49 | color: var(--selected-result-subtitle-color); 50 | } 51 | } 52 | 53 | .details { 54 | position: relative; 55 | display: flex; 56 | flex-grow: 2; 57 | flex-wrap: wrap; 58 | flex-direction: column; 59 | align-items: flex-start; 60 | justify-content: center; 61 | height: 90%; 62 | } 63 | -------------------------------------------------------------------------------- /app/main/components/ResultsList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { List } from 'react-virtualized' 4 | import { RESULT_HEIGHT } from 'main/constants/ui' 5 | 6 | import Row from './Row' 7 | import styles from './styles.module.css' 8 | 9 | function ResultsList({ 10 | results, selected, visibleResults, onSelect, mainInputFocused, onItemHover 11 | }) { 12 | const rowRenderer = ({ index, key, style }) => { 13 | const result = results[index] 14 | const attrs = { 15 | ...result, 16 | // TODO: think about events 17 | // In some cases action should be executed and window should be closed 18 | // In some cases we should autocomplete value 19 | selected: index === selected, 20 | onSelect: (event) => onSelect(result, event), 21 | // Move selection to item under cursor 22 | onMouseMove: (event) => { 23 | const { movementX, movementY } = event.nativeEvent 24 | if (index === selected || !mainInputFocused) return false 25 | 26 | if (movementX || movementY) { 27 | // Hover item only when we had real movement of mouse 28 | // We should prevent changing of selection when user uses keyboard 29 | onItemHover(index) 30 | } 31 | }, 32 | } 33 | return 34 | } 35 | 36 | const renderPreview = () => { 37 | const selectedResult = results[selected] 38 | if (!selectedResult.getPreview) return null 39 | 40 | const preview = selectedResult.getPreview() 41 | 42 | if (typeof preview === 'string') { 43 | // Fallback for html previews intead of react component 44 | return
45 | } 46 | return preview 47 | } 48 | 49 | const classNames = [styles.resultsList, mainInputFocused ? styles.focused : styles.unfocused].join(' ') 50 | if (results.length === 0) return null 51 | 52 | return ( 53 |
54 | 66 |
67 | {renderPreview()} 68 |
69 |
70 | ) 71 | } 72 | 73 | ResultsList.propTypes = { 74 | results: PropTypes.array, 75 | selected: PropTypes.number, 76 | visibleResults: PropTypes.number, 77 | onItemHover: PropTypes.func, 78 | onSelect: PropTypes.func, 79 | mainInputFocused: PropTypes.bool, 80 | } 81 | 82 | export default ResultsList 83 | -------------------------------------------------------------------------------- /app/main/components/ResultsList/styles.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | flex-direction: row; 4 | flex-wrap: nowrap; 5 | border-top: var(--main-border); 6 | height: 100%; 7 | position: relative; 8 | } 9 | 10 | .unfocused { 11 | opacity: .5; 12 | } 13 | 14 | .resultsList { 15 | overflow-y: auto; 16 | width: 100%; 17 | min-width: 250px; 18 | } 19 | 20 | .preview { 21 | flex-grow: 2; 22 | padding: 10px 10px 20px 10px; 23 | background-color: var(--main-background-color); 24 | align-items: center; 25 | display: flex; 26 | max-height: 100%; 27 | position: absolute; 28 | left: 250px; 29 | top: 0; 30 | bottom: 0; 31 | right: 0; 32 | overflow: auto; 33 | /* 34 | Instead of using `justify-content: center` we have to use this hack. 35 | In this case child element that is bigger than `.preview ` will be placed on left border 36 | instead of moving outside of container 37 | */ 38 | &::before, &::after { 39 | content: ''; 40 | margin: auto; 41 | } 42 | 43 | &:empty { 44 | display: none; 45 | } 46 | 47 | input { 48 | border: var(--preview-input-border); 49 | background: var(--preview-input-background); 50 | color: var(--preview-input-color); 51 | } 52 | 53 | :global { 54 | /* Styles for react-select */ 55 | .Select { 56 | .Select-control { 57 | border: var(--preview-input-border); 58 | background: var(--preview-input-background); 59 | color: var(--preview-input-color); 60 | } 61 | .Select-menu-outer { 62 | border: var(--preview-input-border); 63 | background: var(--preview-input-background); 64 | } 65 | .Select-input input { 66 | border: 0; 67 | } 68 | .Select-value-label { 69 | color: var(--preview-input-color) !important; 70 | } 71 | .Select-option { 72 | background: var(--preview-input-background); 73 | color: var(--preview-input-color); 74 | &.is-selected { 75 | color: var(--selected-result-title-color); 76 | background: var(--selected-result-background); 77 | } 78 | &.is-focused { 79 | color: var(--selected-result-title-color); 80 | background: var(--selected-result-background); 81 | filter: opacity(50%); 82 | } 83 | } 84 | .Select-option.is-selected { 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/main/components/SmartIcon/getFileIcon/index.ts: -------------------------------------------------------------------------------- 1 | const { memoize } = require('cerebro-tools') 2 | 3 | const empty = () => Promise.reject() 4 | 5 | /* eslint-disable global-require */ 6 | /* eslint-disable import/no-mutable-exports */ 7 | 8 | let getFileIcon = empty 9 | 10 | if (process.platform === 'darwin') { 11 | getFileIcon = require('./mac') 12 | } 13 | 14 | if (process.platform === 'win32') { 15 | getFileIcon = require('./windows') 16 | } 17 | 18 | module.exports = memoize(getFileIcon) 19 | 20 | /* eslint-enable global-require */ 21 | /* eslint-disable import/no-mutable-exports */ 22 | -------------------------------------------------------------------------------- /app/main/components/SmartIcon/getFileIcon/mac.ts: -------------------------------------------------------------------------------- 1 | const remote = require('@electron/remote') 2 | 3 | /** 4 | * Get system icon for file 5 | * 6 | * @param {String} path File path 7 | * @param {Number} options.width 8 | * @param {[type]} options.height 9 | * @return {Promise} Promise resolves base64-encoded source of icon 10 | */ 11 | module.exports = async function getFileIcon(path: string, { width = 24, height = 24 } = {}) { 12 | // eslint-disable-next-line global-require 13 | const plist = require('simple-plist') 14 | 15 | if (!path.endsWith('.app') && !path.endsWith('.app/')) { 16 | const icon = await remote.nativeImage.createThumbnailFromPath(path, { width, height }) 17 | return icon.toDataURL() 18 | } 19 | const { CFBundleIconFile } = plist.readFileSync(`${path}/Contents/Info.plist`) 20 | 21 | if (!CFBundleIconFile) { 22 | return null 23 | } 24 | 25 | const iconFileName = CFBundleIconFile.endsWith('.icns') 26 | ? CFBundleIconFile : `${CFBundleIconFile}.icns` 27 | const icon = await remote.nativeImage 28 | .createThumbnailFromPath( 29 | `${path}/Contents/Resources/${iconFileName}`, 30 | { width, height } 31 | ) 32 | return icon.toDataURL() 33 | } 34 | -------------------------------------------------------------------------------- /app/main/components/SmartIcon/getFileIcon/windows.ts: -------------------------------------------------------------------------------- 1 | const remote = require('@electron/remote') 2 | 3 | /** 4 | * Get system icon for file 5 | * 6 | * @param {String} path File path 7 | * @return {Promise} Promise resolves base64-encoded source of icon 8 | */ 9 | module.exports = async function getFileIcon(path: string) { 10 | const nativeIcon = await remote.app.getFileIcon(path) 11 | return nativeIcon.toDataURL() 12 | } 13 | -------------------------------------------------------------------------------- /app/main/components/SmartIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import FontAwesome from 'react-fontawesome' 3 | 4 | // @ts-ignore 5 | import getFileIcon from './getFileIcon' 6 | 7 | interface IconProps { 8 | className?: string 9 | path: string 10 | } 11 | 12 | /** 13 | * Check if provided string is an image src 14 | * It can be a path to png/jpg/svg image or data-uri 15 | * 16 | * @param {String} path 17 | * @return {Boolean} 18 | */ 19 | const isImage = (path: string) => !!path.match(/(^data:)|(\.(png|jpe?g|svg|ico)$)/) 20 | 21 | /** 22 | * Check if provided string matches a FontAwesome icon 23 | */ 24 | const isFontAwesome = (path: string) => path.match(/^fa-(.+)$/) 25 | 26 | /** 27 | * Render icon for provided path. 28 | * It will render the same icon, that you see in Finder 29 | * 30 | * @param {String} options.className 31 | * @param {String} options.path 32 | * @return {Function} 33 | */ 34 | function FileIcon({ className, path }:IconProps) { 35 | const src = getFileIcon(path) 36 | 37 | return src ? : null 38 | } 39 | 40 | /** 41 | * This component renders: 42 | * – if `options.path` is an image this image will be rendered. Supported formats are: 43 | * png, jpg, svg and icns 44 | * - otherwise it will render icon for provided path, that you can see in Finder 45 | * @param {String} options.className 46 | * @param {String} options.path 47 | * @return {Function} 48 | */ 49 | function SmartIcon({ className, path }: IconProps) { 50 | const fontAwesomeMatches = isFontAwesome(path) 51 | if (fontAwesomeMatches) { 52 | return ( 53 | 58 | ) 59 | } 60 | 61 | return ( 62 | isImage(path) 63 | ? {path} 64 | : 65 | ) 66 | } 67 | 68 | export default memo(SmartIcon) 69 | -------------------------------------------------------------------------------- /app/main/components/StatusBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // @ts-ignore 4 | import styles from './styles.module.css' 5 | 6 | interface StatusBarProps { 7 | value?: string 8 | } 9 | function StatusBar({ value }: StatusBarProps) { 10 | return
{value}
11 | } 12 | 13 | export default StatusBar 14 | -------------------------------------------------------------------------------- /app/main/components/StatusBar/styles.module.css: -------------------------------------------------------------------------------- 1 | .statusBar { 2 | position: absolute; 3 | bottom: 0; 4 | right: 0; 5 | padding: 5px; 6 | border-radius: 5px 0 0 0; 7 | border: var(--main-border); 8 | color: var(--secondary-font-color); 9 | background: var(--preview-input-background); 10 | border-width: 1px 0 0 1px; 11 | font-size: .75em; 12 | } -------------------------------------------------------------------------------- /app/main/constants/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const UPDATE_TERM = 'UPDATE_TERM' 2 | export const MOVE_CURSOR = 'MOVE_CURSOR' 3 | export const SELECT_ELEMENT = 'SELECT_ELEMENT' 4 | export const SHOW_RESULT = 'SHOW_RESULT' 5 | export const HIDE_RESULT = 'HIDE_RESULT' 6 | export const UPDATE_RESULT = 'UPDATE_RESULT' 7 | export const RESET = 'RESET' 8 | export const CHANGE_VISIBLE_RESULTS = 'CHANGE_VISIBLE_RESULTS' 9 | export const ICON_LOADED = 'ICON_LOADED' 10 | export const SET_STATUS_BAR_TEXT = 'SET_STATUS_BAR_TEXT' 11 | -------------------------------------------------------------------------------- /app/main/constants/ui.ts: -------------------------------------------------------------------------------- 1 | // Height of main input 2 | export const INPUT_HEIGHT = 45 3 | 4 | // Heigth of default result line 5 | export const RESULT_HEIGHT = 45 6 | 7 | // Width of main window 8 | export const WINDOW_WIDTH = 650 9 | 10 | // Maximum results that would be rendered 11 | export const MAX_RESULTS = 25 12 | 13 | // Results view shows this count of resutls without scrollbar 14 | export const MIN_VISIBLE_RESULTS = 10 15 | -------------------------------------------------------------------------------- /app/main/createWindow.js: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserWindow, globalShortcut, app, shell 3 | } from 'electron' 4 | import debounce from 'lodash/debounce' 5 | import EventEmitter from 'events' 6 | import config from 'lib/config' 7 | 8 | import { 9 | INPUT_HEIGHT, 10 | WINDOW_WIDTH 11 | } from './constants/ui' 12 | 13 | import buildMenu from './createWindow/buildMenu' 14 | import toggleWindow from './createWindow/toggleWindow' 15 | import handleUrl from './createWindow/handleUrl' 16 | 17 | export default ({ src, isDev }) => { 18 | const [x, y] = config.get('winPosition') 19 | 20 | const browserWindowOptions = { 21 | width: WINDOW_WIDTH, 22 | minWidth: WINDOW_WIDTH, 23 | height: INPUT_HEIGHT, 24 | x, 25 | y, 26 | frame: false, 27 | resizable: false, 28 | transparent: true, 29 | show: config.get('firstStart'), 30 | webPreferences: { 31 | nodeIntegration: true, 32 | nodeIntegrationInSubFrames: false, 33 | enableRemoteModule: true, 34 | contextIsolation: false 35 | }, 36 | // Show main window on launch only when application started for the first time 37 | } 38 | 39 | if (process.platform === 'linux') { 40 | browserWindowOptions.type = 'splash' 41 | } 42 | 43 | const mainWindow = new BrowserWindow(browserWindowOptions) 44 | 45 | // Workaround to set the position the first time (centers the window) 46 | config.set('winPosition', mainWindow.getPosition()) 47 | 48 | // Float main window above full-screen apps 49 | mainWindow.setAlwaysOnTop(true, 'modal-panel') 50 | 51 | mainWindow.loadURL(src) 52 | mainWindow.settingsChanges = new EventEmitter() 53 | 54 | // Get global shortcut from app settings 55 | let shortcut = config.get('hotkey') 56 | 57 | // Function to toggle main window 58 | const toggleMainWindow = () => toggleWindow(mainWindow) 59 | // Function to show main window 60 | const showMainWindow = () => { 61 | mainWindow.show() 62 | mainWindow.focus() 63 | } 64 | 65 | // Setup event listeners for main window 66 | globalShortcut.register(shortcut, toggleMainWindow) 67 | 68 | mainWindow.on('blur', () => { 69 | if (!isDev() && config.get('hideOnBlur')) { 70 | // Hide window on blur in production 71 | // In development we usually use developer tools that can blur a window 72 | mainWindow.hide() 73 | } 74 | }) 75 | 76 | // Save window position when it is being moved 77 | mainWindow.on('move', debounce(() => { 78 | if (!mainWindow.isVisible()) { 79 | return 80 | } 81 | 82 | config.set('winPosition', mainWindow.getPosition()) 83 | }, 100)) 84 | 85 | mainWindow.on('close', app.quit) 86 | 87 | mainWindow.webContents.on('new-window', (event, url) => { 88 | shell.openExternal(url) 89 | event.preventDefault() 90 | }) 91 | 92 | mainWindow.webContents.on('will-navigate', (event, url) => { 93 | if (url !== mainWindow.webContents.getURL()) { 94 | shell.openExternal(url) 95 | event.preventDefault() 96 | } 97 | }) 98 | 99 | // Change global hotkey if it is changed in app settings 100 | mainWindow.settingsChanges.on('hotkey', (value) => { 101 | globalShortcut.unregister(shortcut) 102 | shortcut = value 103 | globalShortcut.register(shortcut, toggleMainWindow) 104 | }) 105 | 106 | // Change theme css file 107 | mainWindow.settingsChanges.on('theme', (value) => { 108 | mainWindow.webContents.send('message', { 109 | message: 'updateTheme', 110 | payload: value 111 | }) 112 | }) 113 | 114 | mainWindow.settingsChanges.on('proxy', (value) => { 115 | mainWindow.webContents.session.setProxy({ 116 | proxyRules: value 117 | }) 118 | }) 119 | 120 | // Handle window.hide: if cleanOnHide value in preferences is true 121 | // we clear all results and show empty window every time 122 | const resetResults = () => { 123 | mainWindow.webContents.send('message', { 124 | message: 'showTerm', 125 | payload: '' 126 | }) 127 | } 128 | 129 | // Handle change of cleanOnHide value in settins 130 | const handleCleanOnHideChange = (value) => { 131 | if (value) { 132 | mainWindow.on('hide', resetResults) 133 | } else { 134 | mainWindow.removeListener('hide', resetResults) 135 | } 136 | } 137 | 138 | // Set or remove handler when settings changed 139 | mainWindow.settingsChanges.on('cleanOnHide', handleCleanOnHideChange) 140 | 141 | // Set initial handler if it is needed 142 | handleCleanOnHideChange(config.get('cleanOnHide')) 143 | 144 | // Restore focus in previous application 145 | // MacOS only: https://github.com/electron/electron/blob/master/docs/api/app.md#apphide-macos 146 | if (process.platform === 'darwin') { 147 | mainWindow.on('hide', () => { 148 | app.hide() 149 | }) 150 | } 151 | 152 | // Show main window when user opens application, but it is already opened 153 | app.on('open-file', (event, path) => handleUrl(mainWindow, path)) 154 | app.on('open-url', (event, path) => handleUrl(mainWindow, path)) 155 | app.on('activate', showMainWindow) 156 | 157 | // Someone tried to run a second instance, we should focus our window. 158 | const shouldQuit = app.requestSingleInstanceLock() 159 | 160 | if (!shouldQuit) { 161 | app.quit() 162 | } else { 163 | app.on('second-instance', () => { 164 | if (mainWindow) { 165 | if (mainWindow.isMinimized()) mainWindow.restore() 166 | mainWindow.focus() 167 | } 168 | }) 169 | } 170 | 171 | // Save in config information, that application has been started 172 | config.set('firstStart', false) 173 | 174 | buildMenu(mainWindow) 175 | return mainWindow 176 | } 177 | -------------------------------------------------------------------------------- /app/main/createWindow/AppTray.js: -------------------------------------------------------------------------------- 1 | import { Menu, Tray, app } from 'electron' 2 | import showWindowWithTerm from './showWindowWithTerm' 3 | import toggleWindow from './toggleWindow' 4 | import checkForUpdates from './checkForUpdates' 5 | 6 | /** 7 | * Class that controls state of icon in menu bar 8 | */ 9 | export default class AppTray { 10 | /** 11 | * @param {String} options.src Absolute path for tray icon 12 | * @param {Function} options.isDev Development mode or not 13 | * @param {BrowserWindow} options.mainWindow 14 | * @param {BrowserWindow} options.backgroundWindow 15 | * @return {AppTray} 16 | */ 17 | constructor(options) { 18 | this.tray = null 19 | this.options = options 20 | } 21 | 22 | /** 23 | * Show application icon in menu bar 24 | */ 25 | show() { 26 | const tray = new Tray(this.options.src) 27 | tray.setToolTip('Cerebro') 28 | tray.setContextMenu(this.buildMenu()) 29 | this.tray = tray 30 | } 31 | 32 | setIsDev(isDev) { 33 | this.options.isDev = isDev 34 | if (this.tray) { 35 | this.tray.setContextMenu(this.buildMenu()) 36 | } 37 | } 38 | 39 | buildMenu() { 40 | const { mainWindow, backgroundWindow, isDev } = this.options 41 | const separator = { type: 'separator' } 42 | 43 | const template = [ 44 | { 45 | label: 'Toggle Cerebro', 46 | click: () => toggleWindow(mainWindow) 47 | }, 48 | separator, 49 | { 50 | label: 'Plugins', 51 | click: () => showWindowWithTerm(mainWindow, 'plugins'), 52 | }, 53 | { 54 | label: 'Preferences...', 55 | click: () => showWindowWithTerm(mainWindow, 'Cerebro Settings'), 56 | }, 57 | separator, 58 | { 59 | label: 'Check for updates', 60 | click: checkForUpdates, 61 | }, 62 | separator, 63 | ] 64 | 65 | if (isDev) { 66 | template.push(separator) 67 | template.push({ 68 | label: 'Development', 69 | submenu: [ 70 | { 71 | label: 'DevTools (main)', 72 | accelerator: 'CmdOrCtrl+Shift+I', 73 | click: () => mainWindow.webContents.openDevTools({ mode: 'detach' }) 74 | }, 75 | { 76 | label: 'DevTools (background)', 77 | accelerator: 'CmdOrCtrl+Shift+B', 78 | click: () => backgroundWindow.webContents.openDevTools({ mode: 'detach' }) 79 | }, 80 | { 81 | label: 'Reload', 82 | click: () => { 83 | app.relaunch() 84 | app.exit() 85 | } 86 | }] 87 | }) 88 | } 89 | 90 | template.push(separator) 91 | template.push({ 92 | label: 'Quit Cerebro', 93 | click: () => app.quit() 94 | }) 95 | 96 | const menu = Menu.buildFromTemplate(template) 97 | Menu.setApplicationMenu(menu) 98 | 99 | return menu 100 | } 101 | 102 | /** 103 | * Hide icon in menu bar 104 | */ 105 | hide() { 106 | if (this.tray) { 107 | this.tray.destroy() 108 | this.tray = null 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/main/createWindow/autoStart.js: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import AutoLaunch from 'auto-launch' 3 | 4 | const isLinux = !['win32', 'darwin'].includes(process.platform) 5 | const isDevelopment = process.env.NODE_ENV === 'development' 6 | 7 | const appLauncher = isLinux 8 | ? new AutoLaunch({ name: 'Cerebro' }) 9 | : null 10 | 11 | const isEnabled = async () => ( 12 | isLinux 13 | ? appLauncher.isEnabled() 14 | : app.getLoginItemSettings().openAtLogin 15 | ) 16 | 17 | const set = async (openAtLogin) => { 18 | const openAtStartUp = openAtLogin && !isDevelopment 19 | if (isLinux) { 20 | return openAtStartUp 21 | ? appLauncher.enable() 22 | : appLauncher.disable() 23 | } 24 | 25 | return app.setLoginItemSettings({ openAtLogin: openAtStartUp }) 26 | } 27 | 28 | export default { isEnabled, set } 29 | -------------------------------------------------------------------------------- /app/main/createWindow/buildMenu.js: -------------------------------------------------------------------------------- 1 | import { Menu, shell, app } from 'electron' 2 | 3 | export default (mainWindow) => { 4 | const template = [{ 5 | label: 'Electron', 6 | submenu: [{ 7 | label: 'About ElectronReact', 8 | selector: 'orderFrontStandardAboutPanel:' 9 | }, 10 | { type: 'separator' }, 11 | { 12 | label: 'Services', 13 | submenu: [] 14 | }, 15 | { type: 'separator' }, 16 | { 17 | label: 'Hide ElectronReact', 18 | accelerator: 'Command+H', 19 | selector: 'hide:' 20 | }, 21 | { 22 | label: 'Hide Others', 23 | accelerator: 'Command+Shift+H', 24 | selector: 'hideOtherApplications:' 25 | }, 26 | { 27 | label: 'Show All', 28 | selector: 'unhideAllApplications:' 29 | }, 30 | { type: 'separator' }, 31 | { 32 | label: 'Quit', 33 | accelerator: 'Command+Q', 34 | click() { app.quit() } 35 | }] 36 | }, 37 | { 38 | label: 'Edit', 39 | submenu: [{ 40 | label: 'Undo', 41 | accelerator: 'Command+Z', 42 | selector: 'undo:' 43 | }, 44 | { 45 | label: 'Redo', 46 | accelerator: 'Shift+Command+Z', 47 | selector: 'redo:' 48 | }, 49 | { type: 'separator' }, 50 | { 51 | label: 'Cut', 52 | accelerator: 'Command+X', 53 | selector: 'cut:' 54 | }, 55 | { 56 | label: 'Copy', 57 | accelerator: 'Command+C', 58 | selector: 'copy:' 59 | }, 60 | { 61 | label: 'Paste', 62 | accelerator: 'Command+V', 63 | selector: 'paste:' 64 | }, 65 | { 66 | label: 'Select All', 67 | accelerator: 'Command+A', 68 | selector: 'selectAll:' 69 | }] 70 | }, 71 | { 72 | label: 'View', 73 | submenu: [{ 74 | label: 'Toggle Full Screen', 75 | accelerator: 'Ctrl+Command+F', 76 | click() { 77 | mainWindow.setFullScreen(!mainWindow.isFullScreen()) 78 | } 79 | }] 80 | }, 81 | { 82 | label: 'Window', 83 | submenu: [{ 84 | label: 'Minimize', 85 | accelerator: 'Command+M', 86 | selector: 'performMiniaturize:' 87 | }, 88 | { 89 | label: 'Close', 90 | accelerator: 'Command+W', 91 | selector: 'performClose:' 92 | }, 93 | { type: 'separator' }, 94 | { 95 | label: 'Bring All to Front', 96 | selector: 'arrangeInFront:' 97 | }] 98 | }, 99 | 100 | { 101 | label: 'Help', 102 | submenu: [{ 103 | label: 'Learn More', 104 | click() { shell.openExternal('http://electron.atom.io') } 105 | }, 106 | { 107 | label: 'Documentation', 108 | click() { shell.openExternal('https://github.com/atom/electron/tree/master/docs#readme') } 109 | }, 110 | { 111 | label: 'Community Discussions', 112 | click() { shell.openExternal('https://discuss.atom.io/c/electron') } 113 | }, 114 | { 115 | label: 'Search Issues', 116 | click() { shell.openExternal('https://github.com/atom/electron/issues') } 117 | }] 118 | }] 119 | 120 | const menu = Menu.buildFromTemplate(template) 121 | Menu.setApplicationMenu(menu) 122 | } 123 | -------------------------------------------------------------------------------- /app/main/createWindow/checkForUpdates.js: -------------------------------------------------------------------------------- 1 | import { dialog, app, shell } from 'electron' 2 | import { autoUpdater } from 'electron-updater' 3 | 4 | const currentVersion = app.getVersion() 5 | const DEFAULT_DOWNLOAD_URL = 'https://github.com/cerebroapp/cerebro/releases' 6 | 7 | const TITLE = 'Cerebro Updates' 8 | 9 | const PLATFORM_EXTENSIONS = { 10 | darwin: 'dmg', 11 | linux: 'AppImage', 12 | win32: 'exe' 13 | } 14 | 15 | const { platform } = process 16 | const installerExtension = PLATFORM_EXTENSIONS[platform] 17 | 18 | const findInstaller = (assets) => { 19 | if (!installerExtension) return DEFAULT_DOWNLOAD_URL 20 | 21 | const regexp = new RegExp(`\.${installerExtension}$`) 22 | const downloadUrl = assets 23 | .find(({ url }) => url.match(regexp)) 24 | 25 | return downloadUrl || DEFAULT_DOWNLOAD_URL 26 | } 27 | 28 | export default async () => { 29 | try { 30 | const release = await autoUpdater.checkForUpdates() 31 | if (release) { 32 | const { updateInfo: { version, files } } = release 33 | dialog.showMessageBox({ 34 | buttons: ['Skip', 'Download'], 35 | defaultId: 1, 36 | cancelId: 0, 37 | title: TITLE, 38 | message: `New version available: ${version}`, 39 | detail: 'Click download to get it now', 40 | }, (response) => { 41 | if (response === 1) { 42 | const url = findInstaller(files) 43 | shell.openExternal(url) 44 | } 45 | }) 46 | } else { 47 | dialog.showMessageBox({ 48 | title: TITLE, 49 | message: `You are using latest version of Cerebro (${currentVersion})`, 50 | buttons: [] 51 | }) 52 | } 53 | } catch (err) { 54 | console.log('Catch error!', err) 55 | dialog.showErrorBox(TITLE, 'Error fetching latest version') 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/main/createWindow/handleUrl.js: -------------------------------------------------------------------------------- 1 | import { parse } from 'url' 2 | 3 | import showWindowWithTerm from './showWindowWithTerm' 4 | 5 | export default (mainWindow, url) => { 6 | const { host: action, query } = parse(url, { parseQueryString: true }) 7 | // Currently only search action supported. 8 | // We can extend this handler to support more 9 | // like `plugins/install` or do something plugin-related 10 | if (action === 'search') { 11 | showWindowWithTerm(mainWindow, query.term) 12 | } else { 13 | showWindowWithTerm(mainWindow, url) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/main/createWindow/showWindowWithTerm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Show main window with updated search term 3 | * 4 | * @return {BrowserWindow} appWindow 5 | */ 6 | export default (appWindow: any, term: string) => { 7 | appWindow.show() 8 | appWindow.focus() 9 | appWindow.webContents.send('message', { 10 | message: 'showTerm', 11 | payload: term 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /app/main/createWindow/toggleWindow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Show or hide main window 3 | * @return {BrowserWindow} appWindow 4 | */ 5 | export default (appWindow: any) => { 6 | if (appWindow.isVisible()) { 7 | appWindow.blur() // once for blurring the content of the window(?) 8 | appWindow.blur() // twice somehow restores focus to prev foreground window 9 | appWindow.hide() 10 | } else { 11 | appWindow.show() 12 | appWindow.focus() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/main/css/global.css: -------------------------------------------------------------------------------- 1 | @import "system-font.css"; 2 | @import url("~normalize.css/normalize.css"); 3 | @import url("~react-virtualized/styles.css"); 4 | 5 | html, 6 | body { 7 | margin: 0; 8 | padding: 0; 9 | background-color: var(--main-background-color); 10 | color: var(--main-font-color); 11 | } 12 | 13 | body { 14 | position: relative; 15 | height: 100vh; 16 | font-family: var(--main-font); 17 | overflow-y: hidden; 18 | } 19 | 20 | #root { 21 | height: 100%; 22 | } 23 | -------------------------------------------------------------------------------- /app/main/css/system-font.css: -------------------------------------------------------------------------------- 1 | /*! system-font.css v1.1.0 | CC0-1.0 License | github.com/jonathantneal/system-font-face */ 2 | 3 | @font-face { 4 | font-family: system; 5 | font-style: normal; 6 | font-weight: 300; 7 | src: local(".SFNSText-Light"), local(".HelveticaNeueDeskInterface-Light"), local(".LucidaGrandeUI"), local("Ubuntu Light"), local("Segoe UI Light"), local("Roboto-Light"), local("DroidSans"), local("Tahoma"); 8 | } 9 | 10 | @font-face { 11 | font-family: system; 12 | font-style: italic; 13 | font-weight: 300; 14 | src: local(".SFNSText-LightItalic"), local(".HelveticaNeueDeskInterface-Italic"), local(".LucidaGrandeUI"), local("Ubuntu Light Italic"), local("Segoe UI Light Italic"), local("Roboto-LightItalic"), local("DroidSans"), local("Tahoma"); 15 | } 16 | 17 | @font-face { 18 | font-family: system; 19 | font-style: normal; 20 | font-weight: 400; 21 | src: local(".SFNSText-Regular"), local(".HelveticaNeueDeskInterface-Regular"), local(".LucidaGrandeUI"), local("Ubuntu"), local("Segoe UI"), local("Roboto-Regular"), local("DroidSans"), local("Tahoma"); 22 | } 23 | 24 | @font-face { 25 | font-family: system; 26 | font-style: italic; 27 | font-weight: 400; 28 | src: local(".SFNSText-Italic"), local(".HelveticaNeueDeskInterface-Italic"), local(".LucidaGrandeUI"), local("Ubuntu Italic"), local("Segoe UI Italic"), local("Roboto-Italic"), local("DroidSans"), local("Tahoma"); 29 | } 30 | 31 | @font-face { 32 | font-family: system; 33 | font-style: normal; 34 | font-weight: 500; 35 | src: local(".SFNSText-Medium"), local(".HelveticaNeueDeskInterface-MediumP4"), local(".LucidaGrandeUI"), local("Ubuntu Medium"), local("Segoe UI Semibold"), local("Roboto-Medium"), local("DroidSans-Bold"), local("Tahoma Bold"); 36 | } 37 | 38 | @font-face { 39 | font-family: system; 40 | font-style: italic; 41 | font-weight: 500; 42 | src: local(".SFNSText-MediumItalic"), local(".HelveticaNeueDeskInterface-MediumItalicP4"), local(".LucidaGrandeUI"), local("Ubuntu Medium Italic"), local("Segoe UI Semibold Italic"), local("Roboto-MediumItalic"), local("DroidSans-Bold"), local("Tahoma Bold"); 43 | } 44 | 45 | @font-face { 46 | font-family: system; 47 | font-style: normal; 48 | font-weight: 700; 49 | src: local(".SFNSText-Bold"), local(".HelveticaNeueDeskInterface-Bold"), local(".LucidaGrandeUI"), local("Ubuntu Bold"), local("Roboto-Bold"), local("DroidSans-Bold"), local("Segoe UI Bold"), local("Tahoma Bold"); 50 | } 51 | 52 | @font-face { 53 | font-family: system; 54 | font-style: italic; 55 | font-weight: 700; 56 | src: local(".SFNSText-BoldItalic"), local(".HelveticaNeueDeskInterface-BoldItalic"), local(".LucidaGrandeUI"), local("Ubuntu Bold Italic"), local("Roboto-BoldItalic"), local("DroidSans-Bold"), local("Segoe UI Bold Italic"), local("Tahoma Bold"); 57 | } 58 | -------------------------------------------------------------------------------- /app/main/css/themes/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Main fonts and colors */ 3 | --main-background-color: rgba(62, 65, 67, 1); 4 | --main-font: system, sans-serif; 5 | --main-font-color: white; 6 | 7 | /* border styles */ 8 | --main-border: 1px solid #686869; 9 | 10 | /* Secondary fonts and colors */ 11 | --secondary-font-color: #9B9D9F; 12 | 13 | /* results list */ 14 | --result-background: transparent; 15 | --result-title-color: var(--main-font-color); 16 | --result-subtitle-color: #cccccc; 17 | 18 | /* selected result */ 19 | --selected-result-title-color: white; 20 | --selected-result-subtitle-color: var(--result-subtitle-color); 21 | --selected-result-background: #1972D6; 22 | 23 | /* scrollbar */ 24 | --scroll-background: var(--main-background-color); 25 | --scroll-track: #2E2E2C; 26 | --scroll-track-active: var(--scroll-track); 27 | --scroll-track-hover: var(--scroll-track); 28 | --scroll-thumb: var(--secondary-font-color); 29 | --scroll-thumb-hover: var(--scroll-thumb); 30 | --scroll-thumb-active: var(--main-font-color); 31 | --scroll-width: 5px; 32 | --scroll-height: 5px; 33 | 34 | /* inputs */ 35 | --preview-input-background: #2E2E2C; 36 | --preview-input-color: var(--main-font-color); 37 | --preview-input-border: 0; 38 | 39 | /* filter for previews */ 40 | --preview-filter: invert(100%) hue-rotate(180deg) contrast(80%); 41 | } 42 | -------------------------------------------------------------------------------- /app/main/css/themes/light.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Main fonts and colors */ 3 | --main-background-color: rgba(255, 255, 255, 1); 4 | --main-font: system, sans-serif; 5 | --main-font-color: #000000; 6 | 7 | /* border styles */ 8 | --main-border: 1px solid #eee; 9 | 10 | /* Secondary fonts and colors */ 11 | --secondary-font-color: #999; 12 | 13 | /* results list */ 14 | --result-background: transparent; 15 | --result-title-color: var(--main-font-color); 16 | --result-subtitle-color: #cccccc; 17 | 18 | /* selected result */ 19 | --selected-result-title-color: white; 20 | --selected-result-subtitle-color: var(--result-subtitle-color); 21 | --selected-result-background: rgba(18, 110, 219, 1); 22 | 23 | /* scrollbar */ 24 | --scroll-background: var(--main-background-color); 25 | --scroll-track: #e0e0e0; 26 | --scroll-track-active: var(--scroll-track); 27 | --scroll-track-hover: var(--scroll-track); 28 | --scroll-thumb: var(--secondary-font-color); 29 | --scroll-thumb-hover: var(--scroll-thumb); 30 | --scroll-thumb-active: var(--main-font-color); 31 | --scroll-width: 5px; 32 | --scroll-height: 5px; 33 | 34 | /* inputs */ 35 | --preview-input-background: white; 36 | --preview-input-color: var(--main-font-color); 37 | --preview-input-border: 1px solid #ccc; 38 | 39 | /* filter for previews */ 40 | --preview-filter: none; 41 | } 42 | -------------------------------------------------------------------------------- /app/main/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cerebro 7 | 8 | 19 | 20 | 21 |
22 |
23 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/main/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | 5 | import initializePlugins from 'lib/initializePlugins' 6 | import { on } from 'lib/rpc' 7 | import config from 'lib/config' 8 | import { updateTerm } from './actions/search' 9 | import store from './store' 10 | import Cerebro from './components/Cerebro' 11 | import './css/global.css' 12 | 13 | global.React = React 14 | global.ReactDOM = ReactDOM 15 | global.isBackground = false 16 | 17 | /** 18 | * Change current theme 19 | * 20 | * @param {String} src Absolute path to new theme css file 21 | */ 22 | const changeTheme = (src) => { 23 | document.getElementById('cerebro-theme').href = src 24 | } 25 | 26 | // Set theme from config 27 | changeTheme(config.get('theme')) 28 | 29 | // Render main container 30 | ReactDOM.render( 31 | 32 | 33 | , 34 | document.getElementById('root') 35 | ) 36 | 37 | // Initialize plugins 38 | initializePlugins() 39 | 40 | // Handle `showTerm` rpc event and replace search term with payload 41 | on('showTerm', (term) => store.dispatch(updateTerm(term))) 42 | 43 | on('update-downloaded', () => ( 44 | new Notification('Cerebro: update is ready to install', { 45 | body: 'New version is downloaded and will be automatically installed on quit' 46 | }) 47 | )) 48 | 49 | // Handle `updateTheme` rpc event and change current theme 50 | on('updateTheme', changeTheme) 51 | -------------------------------------------------------------------------------- /app/main/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import search from './search' 3 | import statusBar from './statusBar' 4 | 5 | const rootReducer = combineReducers({ 6 | search, 7 | statusBar 8 | }) 9 | 10 | export default rootReducer 11 | -------------------------------------------------------------------------------- /app/main/reducers/search.js: -------------------------------------------------------------------------------- 1 | /* eslint no-shadow: [2, { "allow": ["comments"] }] */ 2 | import uniq from 'lodash/uniq' 3 | import orderBy from 'lodash/orderBy' 4 | 5 | import { 6 | UPDATE_TERM, 7 | MOVE_CURSOR, 8 | SELECT_ELEMENT, 9 | SHOW_RESULT, 10 | HIDE_RESULT, 11 | UPDATE_RESULT, 12 | RESET, 13 | CHANGE_VISIBLE_RESULTS 14 | } from 'main/constants/actionTypes' 15 | 16 | import { MIN_VISIBLE_RESULTS } from 'main/constants/ui' 17 | 18 | const initialState = { 19 | // Search term in main input 20 | term: '', 21 | // Store last used term in separate field 22 | prevTerm: '', 23 | // Array of ids of results 24 | resultIds: [], 25 | resultsById: {}, 26 | // Index of selected result 27 | selected: 0, 28 | // Count of visible results 29 | visibleResults: MIN_VISIBLE_RESULTS 30 | } 31 | 32 | 33 | /** 34 | * Normalize index of selected item. 35 | * Index should be >= 0 and <= results.length 36 | * 37 | * @param {Integer} index 38 | * @param {Integer} length current count of found results 39 | * @return {Integer} normalized index 40 | */ 41 | function normalizeSelection(index, length) { 42 | const normalizedIndex = index % length 43 | return normalizedIndex < 0 ? length + normalizedIndex : normalizedIndex 44 | } 45 | 46 | // Function that does nothing 47 | const noon = () => {} 48 | 49 | function normalizeResult(result) { 50 | return { 51 | ...result, 52 | onFocus: result.onFocus || noon, 53 | onBlur: result.onFocus || noon, 54 | onSelect: result.onSelect || noon, 55 | } 56 | } 57 | 58 | export default function search(state = initialState, { type, payload }) { 59 | switch (type) { 60 | case UPDATE_TERM: { 61 | return { 62 | ...state, 63 | term: payload, 64 | resultIds: [], 65 | selected: 0 66 | } 67 | } 68 | case MOVE_CURSOR: { 69 | let selected = state.selected 70 | const resultIds = state.resultIds 71 | selected += payload 72 | selected = normalizeSelection(selected, resultIds.length) 73 | return { 74 | ...state, 75 | selected, 76 | } 77 | } 78 | case SELECT_ELEMENT: { 79 | const selected = normalizeSelection(payload, state.resultIds.length) 80 | return { 81 | ...state, 82 | selected, 83 | } 84 | } 85 | case UPDATE_RESULT: { 86 | const { id, result } = payload 87 | const { resultsById } = state 88 | const newResult = { 89 | ...resultsById[id], 90 | ...result 91 | } 92 | return { 93 | ...state, 94 | resultsById: { 95 | ...resultsById, 96 | [id]: newResult 97 | } 98 | } 99 | } 100 | case HIDE_RESULT: { 101 | const { id } = payload 102 | let { resultsById, resultIds } = state 103 | resultIds = resultIds.filter(resultId => resultId !== id) 104 | 105 | resultsById = resultIds.reduce((acc, resultId) => ({ 106 | ...acc, 107 | [resultId]: resultsById[resultId] 108 | }), {}) 109 | 110 | return { 111 | ...state, 112 | resultsById, 113 | resultIds 114 | } 115 | } 116 | case SHOW_RESULT: { 117 | const { term, result } = payload 118 | if (term !== state.term) { 119 | // Do not show this result if term was changed 120 | return state 121 | } 122 | let { resultsById, resultIds } = state 123 | 124 | result.forEach((res) => { 125 | resultsById = { 126 | ...resultsById, 127 | [res.id]: normalizeResult(res) 128 | } 129 | resultIds = [...resultIds, res.id] 130 | }) 131 | 132 | return { 133 | ...state, 134 | resultsById, 135 | resultIds: orderBy(uniq(resultIds), id => resultsById[id].order || 0) 136 | } 137 | } 138 | case CHANGE_VISIBLE_RESULTS: { 139 | return { 140 | ...state, 141 | visibleResults: payload, 142 | } 143 | } 144 | case RESET: { 145 | return { 146 | // Do not override last used search term with empty string 147 | ...state, 148 | prevTerm: state.term || state.prevTerm, 149 | resultsById: {}, 150 | resultIds: [], 151 | term: '', 152 | selected: 0, 153 | } 154 | } 155 | default: 156 | return state 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /app/main/reducers/statusBar.js: -------------------------------------------------------------------------------- 1 | /* eslint no-shadow: [2, { "allow": ["comments"] }] */ 2 | 3 | import { 4 | SET_STATUS_BAR_TEXT 5 | } from 'main/constants/actionTypes' 6 | 7 | const initialState = { 8 | text: null 9 | } 10 | 11 | 12 | export default function search(state = initialState, { type, payload }) { 13 | switch (type) { 14 | case SET_STATUS_BAR_TEXT: { 15 | return { 16 | ...state, 17 | text: payload 18 | } 19 | } 20 | default: 21 | return state 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/main/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import thunk from 'redux-thunk' 3 | import rootReducer from '../reducers' 4 | 5 | const enhancer = applyMiddleware(thunk) 6 | 7 | export default function configureStore(initialState) { 8 | return createStore(rootReducer, initialState, enhancer) 9 | } 10 | -------------------------------------------------------------------------------- /app/main/store/index.ts: -------------------------------------------------------------------------------- 1 | import configureStore from './configureStore' 2 | 3 | export default configureStore() 4 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cerebro", 3 | "productName": "Cerebro", 4 | "description": "Cerebro is an open-source launcher to improve your productivity and efficiency", 5 | "version": "0.11.0", 6 | "main": "./main.js", 7 | "license": "MIT", 8 | "author": { 9 | "name": "CerebroApp Organization", 10 | "email": "kelionweb@gmail.com", 11 | "url": "https://github.com/cerebroapp" 12 | }, 13 | "contributors": [ 14 | "Alexandr Subbotin (https://github.com/KELiON)", 15 | "Gustavo Pereira plugin.keyword 17 | const notMatch = (term) => (plugin) => ( 18 | plugin.keyword !== term && `${plugin.keyword} ` !== term 19 | ) 20 | 21 | const pluginToResult = (actions) => (res) => ({ 22 | title: res.name, 23 | icon: res.icon, 24 | term: `${res.keyword} `, 25 | onSelect: (event) => { 26 | event.preventDefault() 27 | actions.replaceTerm(`${res.keyword} `) 28 | } 29 | }) 30 | 31 | /** 32 | * Plugin for autocomplete other plugins 33 | * 34 | * @param {String} options.term 35 | * @param {Function} options.display 36 | */ 37 | const fn = ({ term, display, actions }) => flow( 38 | values, 39 | filter((plugin) => !!plugin.keyword), 40 | partialRight(search, [term, toString]), 41 | filter(notMatch(term)), 42 | map(pluginToResult(actions)), 43 | display 44 | )(allPlugins) 45 | 46 | export default { fn, name: 'Plugins autocomplete' } 47 | -------------------------------------------------------------------------------- /app/plugins/core/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/app/plugins/core/icon.png -------------------------------------------------------------------------------- /app/plugins/core/index.ts: -------------------------------------------------------------------------------- 1 | import autocomplete from './autocomplete' 2 | import quit from './quit' 3 | import plugins from './plugins' 4 | import settings from './settings' 5 | import version from './version' 6 | import reload from './reload' 7 | 8 | export default { 9 | autocomplete, quit, plugins, settings, version, reload 10 | } 11 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/Preview/ActionButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { KeyboardNavItem } from '@cerebroapp/cerebro-ui' 4 | 5 | function ActionButton({ action, onComplete, text }) { 6 | const onSelect = () => { 7 | Promise.all(action()).then(onComplete) 8 | } 9 | return ( 10 | 11 | {text} 12 | 13 | ) 14 | } 15 | 16 | ActionButton.propTypes = { 17 | action: PropTypes.func.isRequired, 18 | text: PropTypes.string.isRequired, 19 | onComplete: PropTypes.func.isRequired, 20 | } 21 | 22 | export default ActionButton 23 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/Preview/FormItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { FormComponents } from '@cerebroapp/cerebro-ui' 4 | 5 | const { Checkbox, Select, Text } = FormComponents 6 | 7 | const components = { 8 | bool: Checkbox, 9 | option: Select, 10 | } 11 | 12 | function FormItem({ 13 | type, value, options, ...props 14 | }) { 15 | const Component = components[type] || Text 16 | 17 | let actualValue = value 18 | if (Component === Select) { 19 | // when the value is a string, we need to find the option that matches it 20 | if (typeof value === 'string' && options) { 21 | actualValue = options.find((option) => option.value === value) 22 | } 23 | } 24 | 25 | return 26 | } 27 | 28 | FormItem.propTypes = { 29 | value: PropTypes.any, 30 | type: PropTypes.string.isRequired, 31 | options: PropTypes.array 32 | } 33 | 34 | export default FormItem 35 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/Preview/Settings.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import config from 'lib/config' 4 | import FormItem from './FormItem' 5 | import styles from './styles.module.css' 6 | 7 | function Settings({ settings, name }) { 8 | const [values, setValues] = useState(() => config.get('plugins')[name] || {}) 9 | 10 | useEffect(() => { 11 | config.set('plugins', { 12 | ...config.get('plugins'), 13 | [name]: values, 14 | }) 15 | }, [values]) 16 | 17 | const changeSetting = async (label, value) => { 18 | setValues((prev) => ({ 19 | ...prev, 20 | [label]: value 21 | })) 22 | } 23 | 24 | const renderSetting = (key) => { 25 | const setting = settings[key] 26 | const { defaultValue, label, ...restProps } = setting 27 | const value = values[key] || defaultValue 28 | 29 | return ( 30 | changeSetting(key, newValue)} 35 | // eslint-disable-next-line react/jsx-props-no-spreading 36 | {...restProps} 37 | /> 38 | ) 39 | } 40 | 41 | return ( 42 |
43 | { Object.keys(settings).map(renderSetting) } 44 |
45 | ) 46 | } 47 | 48 | export default Settings 49 | 50 | Settings.propTypes = { 51 | name: PropTypes.string.isRequired, 52 | settings: PropTypes.object.isRequired, 53 | } 54 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/Preview/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { KeyboardNav, KeyboardNavItem } from '@cerebroapp/cerebro-ui' 4 | import { client } from 'lib/plugins' 5 | import ReactMarkdown from 'react-markdown' 6 | 7 | import ActionButton from './ActionButton.js' 8 | import Settings from './Settings' 9 | import getReadme from '../getReadme' 10 | import styles from './styles.module.css' 11 | import * as format from '../format' 12 | 13 | function Description({ repoName }) { 14 | const isRelative = (src) => !src.match(/^(https?:|data:)/) 15 | 16 | const urlTransform = (src) => { 17 | if (isRelative(src)) return `http://raw.githubusercontent.com/${repoName}/master/${src}` 18 | return src 19 | } 20 | 21 | const [readme, setReadme] = useState(null) 22 | 23 | useEffect(() => { getReadme(repoName).then(setReadme) }, []) 24 | 25 | if (!readme) return null 26 | 27 | return ( 28 | urlTransform(src)}> 29 | {readme} 30 | 31 | ) 32 | } 33 | 34 | Description.propTypes = { 35 | repoName: PropTypes.string.isRequired 36 | } 37 | 38 | function Preview({ onComplete, plugin }) { 39 | const [runningAction, setRunningAction] = useState(null) 40 | const [showDescription, setShowDescription] = useState(null) 41 | const [showSettings, setShowSettings] = useState(null) 42 | 43 | const onCompleteAction = () => { 44 | setRunningAction(null) 45 | onComplete() 46 | } 47 | 48 | const pluginAction = (pluginName, runningActionName) => () => [ 49 | setRunningAction(runningActionName), 50 | client[runningActionName](pluginName) 51 | ] 52 | 53 | const { 54 | name, version, description, repo, 55 | isInstalled = false, 56 | isDebugging = false, 57 | installedVersion, 58 | isUpdateAvailable = false 59 | } = plugin 60 | 61 | const githubRepo = repo && repo.match(/^.+github.com\/([^\/]+\/[^\/]+).*?/) 62 | const settings = plugin?.settings || null 63 | return ( 64 |
65 |

{`${format.name(name)} (${version})`}

66 | 67 |

{format.description(description)}

68 | 69 |
70 | 71 | {settings && ( 72 | setShowSettings((prev) => !prev)}> 73 | Settings 74 | 75 | )} 76 | 77 | {showSettings && } 78 | 79 | {!isInstalled && !isDebugging && ( 80 | 85 | )} 86 | 87 | {isInstalled && ( 88 | 93 | )} 94 | 95 | {isUpdateAvailable && ( 96 | 101 | )} 102 | 103 | {githubRepo && ( 104 | setShowDescription((prev) => !prev)}> 105 | Details 106 | 107 | )} 108 | 109 |
110 |
111 | {showDescription && } 112 |
113 | ) 114 | } 115 | 116 | Preview.propTypes = { 117 | plugin: PropTypes.object.isRequired, 118 | onComplete: PropTypes.func.isRequired, 119 | } 120 | 121 | export default Preview 122 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/Preview/styles.module.css: -------------------------------------------------------------------------------- 1 | .preview { 2 | align-self: flex-start; 3 | width: 100%; 4 | } 5 | 6 | .header { 7 | border-bottom: var(--main-border); 8 | margin-bottom: 15px; 9 | } 10 | 11 | .markdown { 12 | font-size: .8em; 13 | align-self: flex-start; 14 | font-size: 16px; 15 | padding: 0 10px; 16 | p { 17 | font-size: 1em; 18 | margin: 0 0 10px; 19 | } 20 | h1, h2, h3, h4 { 21 | color: var(--main-font-color); 22 | margin-top: 24px; 23 | margin-bottom: 16px; 24 | font-weight: 500; 25 | line-height: 1.25; 26 | } 27 | h1 { 28 | padding-bottom: 0.3em; 29 | font-size: 2em; 30 | border-bottom: var(--main-border); 31 | } 32 | h2 { 33 | padding-bottom: 0.3em; 34 | font-size: 1.5em; 35 | border-bottom: var(--main-border); 36 | } 37 | pre { 38 | padding: 16px; 39 | overflow: auto; 40 | font-size: 85%; 41 | line-height: 1.45; 42 | filter: invert(10%); 43 | background: var(--main-background-color); 44 | border-radius: 3px; 45 | code { 46 | padding: 0; 47 | background: transparent; 48 | &:before, &:after { 49 | content: none; 50 | } 51 | } 52 | } 53 | blockquote { 54 | border-left: 3px solid #999; 55 | margin: 15px 0; 56 | padding: 5px 15px; 57 | p:last-child { 58 | margin-bottom: 0; 59 | } 60 | } 61 | code { 62 | padding: 0; 63 | padding-top: 0.2em; 64 | padding-bottom: 0.2em; 65 | margin: 0; 66 | font-size: 85%; 67 | background-color: rgba(0,0,0,0.04); 68 | border-radius: 3px; 69 | &:after { 70 | letter-spacing: -0.2em; 71 | content: "\00a0"; 72 | } 73 | &:before { 74 | letter-spacing: -0.2em; 75 | content: "\00a0"; 76 | } 77 | } 78 | img { 79 | max-width: 100%; 80 | } 81 | a { 82 | color: #4078c0; 83 | text-decoration: none; 84 | } 85 | ul { 86 | padding-left: 2em; 87 | list-style-type: disc; 88 | } 89 | li { 90 | list-style-type: disc; 91 | } 92 | li + li { 93 | margin-top: 0.25em; 94 | } 95 | } 96 | 97 | .settingsWrapper { 98 | margin: 15px 0; 99 | } 100 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/StatusBar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styles from './styles.module.css' 4 | 5 | function StatusBar({ value }) { 6 | return
{value}
7 | } 8 | 9 | StatusBar.propTypes = { 10 | value: PropTypes.string.isRequired 11 | } 12 | 13 | export default StatusBar 14 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/StatusBar/styles.module.css: -------------------------------------------------------------------------------- 1 | .statusBar { 2 | position: absolute; 3 | right: 0; 4 | bottom: 0; 5 | z-index: 11; 6 | font-size: 11px; 7 | color: var(--secondary-font-color); 8 | background: var(--preview-input-background); 9 | padding: 5px; 10 | border-radius: 5px 0 0 0; 11 | border-top: var(--main-border); 12 | border-left: var(--main-border); 13 | max-width: 250px; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | } -------------------------------------------------------------------------------- /app/plugins/core/plugins/blacklist.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains plugins that have been blacklisted. 3 | * The main purpose of this is to hide plugins that have been republished under our scope. 4 | * The name must match (case sensitive) the name in the `package.json`. 5 | */ 6 | export default [ 7 | 'cerebro-basic-apps', // @cerebroapp/cerebro-basic-apps 8 | 'cerebro-mac-apps', // @cerebroapp/cerebro-mac-apps 9 | 'cerebro-brew', // @cerebroapp/cerebro-brew 10 | ] 11 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/format.js: -------------------------------------------------------------------------------- 1 | import { 2 | flow, words, capitalize, trim, map, join 3 | } from 'lodash/fp' 4 | 5 | /** 6 | * Remove unnecessary information from plugin description 7 | * like `Cerebro plugin for` 8 | * @param {String} str 9 | * @return {String} 10 | */ 11 | const removeDescriptionNoise = (str) => ( 12 | (str || '').replace(/^cerebro\s?(plugin)?\s?(to|for)?/i, '') 13 | ) 14 | 15 | /** 16 | * Remove unnecessary information from plugin name 17 | * like `cerebro-plugin-` or `cerebro-` 18 | * @param {String} str 19 | * @return {String} 20 | */ 21 | const removeNameNoise = (str) => ( 22 | (str || '').replace(/^cerebro-(plugin)?-?/i, '') 23 | ) 24 | 25 | export const name = (text = '') => flow( 26 | trim, 27 | words, 28 | map(capitalize), 29 | join(' ') 30 | )(removeNameNoise(text.toLowerCase())) 31 | 32 | export const description = flow( 33 | removeDescriptionNoise, 34 | trim, 35 | capitalize, 36 | ) 37 | 38 | export const version = (plugin) => ( 39 | plugin.isUpdateAvailable 40 | ? `${plugin.installedVersion} → ${plugin.version}` 41 | : plugin.version 42 | ) 43 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/getAvailablePlugins.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API endpoint to search all cerebro plugins 3 | * @type {String} 4 | */ 5 | const URL = 'https://registry.npmjs.com/-/v1/search?from=0&size=500&text=keywords:cerebro-plugin,cerebro-extracted-plugin' 6 | 7 | const sortByPopularity = (a, b) => a.score.detail.popularity > b.score.detail.popularity ? -1 : 1 8 | 9 | /** 10 | * Get all available plugins for Cerebro 11 | * @return {Promise} 12 | */ 13 | export default async () => { 14 | if (!navigator.onLine) return [] 15 | try { 16 | const { objects: plugins } = await fetch(URL).then((res) => res.json()) 17 | plugins.sort(sortByPopularity) 18 | 19 | return plugins.map((p) => ({ 20 | name: p.package.name, 21 | version: p.package.version, 22 | description: p.package.description, 23 | homepage: p.package.links.homepage, 24 | repo: p.package.links.repository 25 | })) 26 | } catch (err) { 27 | console.log(err) 28 | return [] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/getDebuggingPlugins.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { modulesDirectory } from 'lib/plugins' 3 | import { lstatSync, readdirSync } from 'fs' 4 | 5 | const isSymlink = (file) => lstatSync(path.join(modulesDirectory, file)).isSymbolicLink() 6 | const isScopeDir = (file) => file.match(/^@/) && lstatSync(path.join(modulesDirectory, file)).isDirectory() 7 | 8 | const getSymlinkedPluginsInFolder = (scope) => { 9 | const files = scope 10 | ? readdirSync(path.join(modulesDirectory, scope)) 11 | : readdirSync(modulesDirectory) 12 | return files.filter((name) => isSymlink(scope ? path.join(scope, name) : name)) 13 | } 14 | 15 | const getNotScopedPluginNames = async () => getSymlinkedPluginsInFolder() 16 | 17 | const getScopedPluginNames = async () => { 18 | // Get all scoped folders 19 | const scopeSubfolders = readdirSync(modulesDirectory).filter(isScopeDir) 20 | 21 | // for each scope, get all plugins 22 | const scopeNames = scopeSubfolders.map((scope) => { 23 | const scopePlugins = getSymlinkedPluginsInFolder(scope) 24 | return scopePlugins.map((plugin) => `${scope}/${plugin}`) 25 | }).flat() // flatten array of arrays 26 | 27 | return scopeNames 28 | } 29 | 30 | /** 31 | * Get list of all plugins that are currently in debugging mode. 32 | * These plugins are symlinked by [create-cerebro-plugin](https://github.com/cerebroapp/create-cerebro-plugin) 33 | * 34 | * @return {Promise} 35 | */ 36 | export default async () => { 37 | const [notScoppedPluginNames, scopedPluginNames] = await Promise.all([ 38 | getNotScopedPluginNames(), 39 | getScopedPluginNames() 40 | ]) 41 | return [...notScoppedPluginNames, ...scopedPluginNames] 42 | } 43 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/getInstalledPlugins.js: -------------------------------------------------------------------------------- 1 | import { packageJsonPath } from 'lib/plugins' 2 | import { readFile } from 'fs/promises' 3 | import externalPlugins from 'plugins/externalPlugins' 4 | 5 | const readPackageJson = async () => { 6 | try { 7 | const fileContent = await readFile(packageJsonPath, { encoding: 'utf8' }) 8 | return JSON.parse(fileContent) 9 | } catch (err) { 10 | console.log(err) 11 | return {} 12 | } 13 | } 14 | 15 | /** 16 | * Get list of all installed plugins with versions 17 | * 18 | * @return {Promise<{[name: string]: Record}>} 19 | */ 20 | export default async () => { 21 | const packageJson = await readPackageJson() 22 | const result = {} 23 | 24 | Object.keys(externalPlugins).forEach((pluginName) => { 25 | result[pluginName] = { ...externalPlugins[pluginName], version: packageJson.dependencies[pluginName] || '0.0.0' } 26 | }) 27 | 28 | return result 29 | } 30 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/getReadme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get plugin Readme.md content 3 | * 4 | * @param {String} repository Repository field from npm package 5 | * @return {Promise} 6 | */ 7 | export default repo => ( 8 | fetch(`https://api.github.com/repos/${repo}/readme`) 9 | .then(response => response.json()) 10 | .then(json => Buffer.from(json.content, 'base64').toString()) 11 | ) 12 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { search } from 'cerebro-tools' 3 | import { shell } from 'electron' 4 | import { partition } from 'lodash' 5 | import { 6 | flow, map, partialRight, tap 7 | } from 'lodash/fp' 8 | import store from 'main/store' 9 | import * as statusBar from 'main/actions/statusBar' 10 | import loadPlugins from './loadPlugins' 11 | import icon from '../icon.png' 12 | import * as format from './format' 13 | import Preview from './Preview' 14 | import initializeAsync from './initializeAsync' 15 | 16 | const toString = ({ name, description }) => [name, description].join(' ') 17 | const categories = [ 18 | ['Development', (plugin) => plugin.isDebugging], 19 | ['Updates', (plugin) => plugin.isUpdateAvailable], 20 | ['Installed', (plugin) => plugin.isInstalled], 21 | ['Available', (plugin) => plugin.name], 22 | ] 23 | 24 | const updatePlugin = async (update, name) => { 25 | const plugins = await loadPlugins() 26 | const updatedPlugin = plugins.find((plugin) => plugin.name === name) 27 | update(name, { 28 | title: `${format.name(updatedPlugin.name)} (${format.version(updatedPlugin)})`, 29 | getPreview: () => ( 30 | updatePlugin(update, name)} 34 | /> 35 | ) 36 | }) 37 | } 38 | 39 | const pluginToResult = (update) => (plugin) => { 40 | if (typeof plugin === 'string') { 41 | return { title: plugin } 42 | } 43 | 44 | return { 45 | icon, 46 | id: plugin.name, 47 | title: `${format.name(plugin.name)} (${format.version(plugin)})`, 48 | subtitle: format.description(plugin.description || ''), 49 | onSelect: () => shell.openExternal(plugin.repo), 50 | getPreview: () => ( 51 | updatePlugin(update, plugin.name)} 55 | /> 56 | ) 57 | } 58 | } 59 | 60 | const categorize = (plugins, callback) => { 61 | const result = [] 62 | let remainder = plugins 63 | 64 | categories.forEach((category) => { 65 | const [title, filter] = category 66 | const [matched, others] = partition(remainder, filter) 67 | if (matched.length) result.push(title, ...matched) 68 | remainder = others 69 | }) 70 | 71 | plugins.splice(0, plugins.length) 72 | plugins.push(...result) 73 | callback() 74 | } 75 | 76 | const fn = ({ 77 | term, display, hide, update 78 | }) => { 79 | const match = term.match(/^plugins?\s*(.+)?$/i) 80 | if (match) { 81 | display({ 82 | icon, 83 | id: 'loading', 84 | title: 'Looking for plugins...' 85 | }) 86 | loadPlugins().then(flow( 87 | partialRight(search, [match[1], toString]), 88 | tap((plugins) => categorize(plugins, () => hide('loading'))), 89 | map(pluginToResult(update)), 90 | display 91 | )) 92 | } 93 | } 94 | 95 | const setStatusBar = (text) => { 96 | store.dispatch(statusBar.setValue(text)) 97 | } 98 | 99 | export default { 100 | icon, 101 | fn, 102 | initializeAsync, 103 | name: 'Manage plugins', 104 | keyword: 'plugins', 105 | onMessage: (type) => { 106 | if (type === 'plugins:start-installation') { 107 | setStatusBar('Installing default plugins...') 108 | } 109 | if (type === 'plugins:finish-installation') { 110 | setTimeout(() => { 111 | setStatusBar(null) 112 | }, 2000) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/initializeAsync.js: -------------------------------------------------------------------------------- 1 | import { client } from 'lib/plugins' 2 | import config from 'lib/config' 3 | import { 4 | flow, filter, map, property 5 | } from 'lodash/fp' 6 | import loadPlugins from './loadPlugins' 7 | import getInstalledPlugins from './getInstalledPlugins' 8 | 9 | const OS_APPS_PLUGIN = { 10 | darwin: '@cerebroapp/cerebro-mac-apps', 11 | DEFAULT: '@cerebroapp/cerebro-basic-apps' 12 | } 13 | 14 | const DEFAULT_PLUGINS = [ 15 | OS_APPS_PLUGIN[process.platform] || OS_APPS_PLUGIN.DEFAULT, 16 | '@cerebroapp/search', 17 | 'cerebro-math', 18 | 'cerebro-converter', 19 | 'cerebro-open-web', 20 | 'cerebro-files-nav' 21 | ] 22 | 23 | /** 24 | * Check plugins for updates and start plugins autoupdater 25 | */ 26 | async function checkForUpdates() { 27 | console.log('Run plugins autoupdate') 28 | const plugins = await loadPlugins() 29 | 30 | const updatePromises = flow( 31 | filter(property('isUpdateAvailable')), 32 | map((plugin) => client.update(plugin.name)) 33 | )(plugins) 34 | 35 | await Promise.all(updatePromises) 36 | 37 | console.log(updatePromises.length > 0 38 | ? `${updatePromises.length} plugins are updated` 39 | : 'All plugins are up to date') 40 | 41 | // Run autoupdate every 12 hours 42 | setTimeout(checkForUpdates, 12 * 60 * 60 * 1000) 43 | } 44 | 45 | /** 46 | * Migrate plugins: default plugins were extracted to separate packages 47 | * so if default plugins are not installed – start installation 48 | */ 49 | async function migratePlugins(sendMessage) { 50 | if (config.get('isMigratedPlugins')) { 51 | // Plugins are already migrated 52 | return 53 | } 54 | 55 | console.log('Start installation of default plugins') 56 | const installedPlugins = await getInstalledPlugins() 57 | 58 | const promises = flow( 59 | filter((plugin) => !installedPlugins[plugin]), 60 | map((plugin) => client.install(plugin)) 61 | )(DEFAULT_PLUGINS) 62 | 63 | if (promises.length > 0) { 64 | sendMessage('plugins:start-installation') 65 | } 66 | 67 | Promise.all(promises).then(() => { 68 | console.log('All default plugins are installed!') 69 | config.set('isMigratedPlugins', true) 70 | sendMessage('plugins:finish-installation') 71 | }) 72 | } 73 | 74 | export default (sendMessage) => { 75 | checkForUpdates() 76 | migratePlugins(sendMessage) 77 | } 78 | -------------------------------------------------------------------------------- /app/plugins/core/plugins/loadPlugins.js: -------------------------------------------------------------------------------- 1 | import { memoize } from 'cerebro-tools' 2 | import validVersion from 'semver/functions/valid' 3 | import compareVersions from 'semver/functions/gt' 4 | import availablePlugins from './getAvailablePlugins' 5 | import getInstalledPlugins from './getInstalledPlugins' 6 | import getDebuggingPlugins from './getDebuggingPlugins' 7 | import blacklist from './blacklist' 8 | 9 | const maxAge = 5 * 60 * 1000 // 5 minutes 10 | 11 | const getAvailablePlugins = memoize(availablePlugins, { maxAge }) 12 | 13 | const parseVersion = (version) => ( 14 | validVersion((version || '').replace(/^\^/, '')) || '0.0.0' 15 | ) 16 | 17 | export default async () => { 18 | const [available, installed, debuggingPlugins] = await Promise.all([ 19 | getAvailablePlugins(), 20 | getInstalledPlugins(), 21 | getDebuggingPlugins() 22 | ]) 23 | 24 | const listOfInstalledPlugins = Object.entries(installed).map(([name, { version }]) => ({ 25 | name, 26 | version, 27 | installedVersion: parseVersion(version), 28 | isInstalled: true, 29 | settings: installed[name].settings, 30 | isUpdateAvailable: false 31 | })) 32 | 33 | const listOfAvailablePlugins = available.map((plugin) => { 34 | const installedVersion = installed[plugin.name]?.version 35 | if (!installedVersion) { return plugin } 36 | 37 | const isUpdateAvailable = compareVersions(plugin.version, parseVersion(installedVersion)) 38 | const installedPluginInfo = listOfInstalledPlugins.find((p) => p.name === plugin.name) 39 | return { 40 | ...plugin, 41 | ...installedPluginInfo, 42 | installedVersion, 43 | isInstalled: true, 44 | isUpdateAvailable 45 | } 46 | }) 47 | 48 | console.log('Debugging Plugins: ', debuggingPlugins) 49 | 50 | const listOfDebuggingPlugins = debuggingPlugins.map((name) => ({ 51 | name, 52 | description: '', 53 | version: 'dev', 54 | isDebugging: true 55 | })) 56 | 57 | const plugins = [ 58 | ...listOfInstalledPlugins, 59 | ...listOfAvailablePlugins, 60 | ...listOfDebuggingPlugins 61 | ].filter((plugin) => !blacklist.includes(plugin.name)) 62 | 63 | return plugins 64 | } 65 | -------------------------------------------------------------------------------- /app/plugins/core/quit/index.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import { search } from 'cerebro-tools' 3 | import icon from '../icon.png' 4 | 5 | const KEYWORDS = ['Quit', 'Exit'] 6 | 7 | const subtitle = 'Quit from Cerebro' 8 | const onSelect = () => ipcRenderer.send('quit') 9 | 10 | /** 11 | * Plugin to exit from Cerebro 12 | * 13 | * @param {String} options.term 14 | * @param {Function} options.display 15 | */ 16 | const fn = ({ term, display }) => { 17 | const result = search(KEYWORDS, term).map((title) => ({ 18 | icon, 19 | title, 20 | subtitle, 21 | onSelect, 22 | term: title, 23 | })) 24 | display(result) 25 | } 26 | 27 | export default { fn } 28 | -------------------------------------------------------------------------------- /app/plugins/core/reload/index.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import icon from '../icon.png' 3 | 4 | const keyword = 'reload' 5 | const title = 'Reload' 6 | const subtitle = 'Reload Cerebro App' 7 | const onSelect = (event) => { 8 | ipcRenderer.send('reload') 9 | event.preventDefault() 10 | } 11 | 12 | /** 13 | * Plugin to reload Cerebro 14 | * 15 | * @param {String} options.term 16 | * @param {Function} options.display 17 | */ 18 | const fn = ({ term, display }) => { 19 | const match = term.match(/^reload\s*/) 20 | 21 | if (match) { 22 | display({ 23 | icon, title, subtitle, onSelect 24 | }) 25 | } 26 | } 27 | 28 | export default { 29 | keyword, fn, icon, name: 'Reload' 30 | } 31 | -------------------------------------------------------------------------------- /app/plugins/core/settings/Settings/Hotkey.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styles from './styles.module.css' 4 | 5 | const ASCII = { 6 | 188: '44', 7 | 109: '45', 8 | 190: '46', 9 | 191: '47', 10 | 192: '96', 11 | 220: '92', 12 | 222: '39', 13 | 221: '93', 14 | 219: '91', 15 | 173: '45', 16 | 187: '61', 17 | 186: '59', 18 | 189: '45' 19 | } 20 | 21 | const SHIFT_UPS = { 22 | 96: '~', 23 | 49: '!', 24 | 50: '@', 25 | 51: '#', 26 | 52: '$', 27 | 53: '%', 28 | 54: '^', 29 | 55: '&', 30 | 56: '*', 31 | 57: '(', 32 | 48: ')', 33 | 45: '_', 34 | 61: '+', 35 | 91: '{', 36 | 93: '}', 37 | 92: '|', 38 | 59: ':', 39 | 39: '"', 40 | 44: '<', 41 | 46: '>', 42 | 47: '?' 43 | } 44 | 45 | const KEYCODES = { 46 | 8: 'Backspace', 47 | 9: 'Tab', 48 | 13: 'Enter', 49 | 27: 'Esc', 50 | 32: 'Space', 51 | 37: 'Left', 52 | 38: 'Up', 53 | 39: 'Right', 54 | 40: 'Down', 55 | 112: 'F1', 56 | 113: 'F2', 57 | 114: 'F3', 58 | 115: 'F4', 59 | 116: 'F5', 60 | 117: 'F6', 61 | 118: 'F7', 62 | 119: 'F8', 63 | 120: 'F9', 64 | 121: 'F10', 65 | 122: 'F11', 66 | 123: 'F12', 67 | } 68 | 69 | const osKeyDelimiter = process.platform === 'darwin' ? '' : '+' 70 | 71 | const keyToSign = (key) => { 72 | if (process.platform === 'darwin') { 73 | return key.replace(/control/i, '⌃') 74 | .replace(/alt/i, '⌥') 75 | .replace(/shift/i, '⇧') 76 | .replace(/command/i, '⌘') 77 | .replace(/enter/i, '↩') 78 | .replace(/backspace/i, '⌫') 79 | } 80 | return key 81 | } 82 | 83 | const charCodeToSign = ({ keyCode, shiftKey }) => { 84 | if (KEYCODES[keyCode]) { 85 | return KEYCODES[keyCode] 86 | } 87 | const valid = (keyCode > 47 && keyCode < 58) // number keys 88 | || (keyCode > 64 && keyCode < 91) // letter keys 89 | || (keyCode > 95 && keyCode < 112) // numpad keys 90 | || (keyCode > 185 && keyCode < 193) // ;=,-./` (in order) 91 | || (keyCode > 218 && keyCode < 223) // [\]' (in order) 92 | if (!valid) { 93 | return null 94 | } 95 | const code = ASCII[keyCode] ? ASCII[keyCode] : keyCode 96 | if (!shiftKey && (code >= 65 && code <= 90)) { 97 | return String.fromCharCode(code + 32) 98 | } 99 | if (shiftKey && SHIFT_UPS[code]) { 100 | return SHIFT_UPS[code] 101 | } 102 | return String.fromCharCode(code) 103 | } 104 | 105 | function Hotkey({ hotkey, onChange }) { 106 | const onKeyDown = (event) => { 107 | if (!event.ctrlKey && !event.altKey && !event.metaKey) { 108 | // Do not allow to set global shorcut without modifier keys 109 | // At least one of alt, cmd or ctrl is required 110 | return 111 | } 112 | event.preventDefault() 113 | event.stopPropagation() 114 | 115 | const key = charCodeToSign(event) 116 | if (!key) return 117 | const keys = [] 118 | 119 | if (event.ctrlKey) keys.push('Control') 120 | if (event.altKey) keys.push('Alt') 121 | if (event.shiftKey) keys.push('Shift') 122 | if (event.metaKey) keys.push('Command') 123 | keys.push(key) 124 | onChange(keys.join('+')) 125 | } 126 | const keys = hotkey.split('+').map(keyToSign).join(osKeyDelimiter) 127 | return ( 128 |
129 | 136 |
137 | ) 138 | } 139 | 140 | Hotkey.propTypes = { 141 | hotkey: PropTypes.string.isRequired, 142 | onChange: PropTypes.func.isRequired, 143 | } 144 | 145 | export default Hotkey 146 | -------------------------------------------------------------------------------- /app/plugins/core/settings/Settings/countries.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { value: 'AF', label: 'Afghanistan' }, 3 | { value: 'AX', label: 'Åland Islands' }, 4 | { value: 'AL', label: 'Albania' }, 5 | { value: 'DZ', label: 'Algeria' }, 6 | { value: 'AS', label: 'American Samoa' }, 7 | { value: 'AD', label: 'Andorra' }, 8 | { value: 'AO', label: 'Angola' }, 9 | { value: 'AI', label: 'Anguilla' }, 10 | { value: 'AQ', label: 'Antarctica' }, 11 | { value: 'AG', label: 'Antigua and Barbuda' }, 12 | { value: 'AR', label: 'Argentina' }, 13 | { value: 'AM', label: 'Armenia' }, 14 | { value: 'AW', label: 'Aruba' }, 15 | { value: 'AU', label: 'Australia' }, 16 | { value: 'AT', label: 'Austria' }, 17 | { value: 'AZ', label: 'Azerbaijan' }, 18 | { value: 'BS', label: 'The Bahamas' }, 19 | { value: 'BH', label: 'Bahrain' }, 20 | { value: 'BD', label: 'Bangladesh' }, 21 | { value: 'BB', label: 'Barbados' }, 22 | { value: 'BY', label: 'Belarus' }, 23 | { value: 'BE', label: 'Belgium' }, 24 | { value: 'BZ', label: 'Belize' }, 25 | { value: 'BJ', label: 'Benin' }, 26 | { value: 'BM', label: 'Bermuda' }, 27 | { value: 'BT', label: 'Bhutan' }, 28 | { value: 'BO', label: 'Bolivia' }, 29 | { value: 'BQ', label: 'Bonaire' }, 30 | { value: 'BA', label: 'Bosnia and Herzegovina' }, 31 | { value: 'BW', label: 'Botswana' }, 32 | { value: 'BV', label: 'Bouvet Island' }, 33 | { value: 'BR', label: 'Brazil' }, 34 | { value: 'IO', label: 'British Indian Ocean Territory' }, 35 | { value: 'UM', label: 'United States Minor Outlying Islands' }, 36 | { value: 'VG', label: 'Virgin Islands (British)' }, 37 | { value: 'VI', label: 'Virgin Islands (U.S.)' }, 38 | { value: 'BN', label: 'Brunei' }, 39 | { value: 'BG', label: 'Bulgaria' }, 40 | { value: 'BF', label: 'Burkina Faso' }, 41 | { value: 'BI', label: 'Burundi' }, 42 | { value: 'KH', label: 'Cambodia' }, 43 | { value: 'CM', label: 'Cameroon' }, 44 | { value: 'CA', label: 'Canada' }, 45 | { value: 'CV', label: 'Cape Verde' }, 46 | { value: 'KY', label: 'Cayman Islands' }, 47 | { value: 'CF', label: 'Central African Republic' }, 48 | { value: 'TD', label: 'Chad' }, 49 | { value: 'CL', label: 'Chile' }, 50 | { value: 'CN', label: 'China' }, 51 | { value: 'CX', label: 'Christmas Island' }, 52 | { value: 'CC', label: 'Cocos (Keeling) Islands' }, 53 | { value: 'CO', label: 'Colombia' }, 54 | { value: 'KM', label: 'Comoros' }, 55 | { value: 'CG', label: 'Republic of the Congo' }, 56 | { value: 'CD', label: 'Democratic Republic of the Congo' }, 57 | { value: 'CK', label: 'Cook Islands' }, 58 | { value: 'CR', label: 'Costa Rica' }, 59 | { value: 'HR', label: 'Croatia' }, 60 | { value: 'CU', label: 'Cuba' }, 61 | { value: 'CW', label: 'Curaçao' }, 62 | { value: 'CY', label: 'Cyprus' }, 63 | { value: 'CZ', label: 'Czech Republic' }, 64 | { value: 'DK', label: 'Denmark' }, 65 | { value: 'DJ', label: 'Djibouti' }, 66 | { value: 'DM', label: 'Dominica' }, 67 | { value: 'DO', label: 'Dominican Republic' }, 68 | { value: 'EC', label: 'Ecuador' }, 69 | { value: 'EG', label: 'Egypt' }, 70 | { value: 'SV', label: 'El Salvador' }, 71 | { value: 'GQ', label: 'Equatorial Guinea' }, 72 | { value: 'ER', label: 'Eritrea' }, 73 | { value: 'EE', label: 'Estonia' }, 74 | { value: 'ET', label: 'Ethiopia' }, 75 | { value: 'FK', label: 'Falkland Islands' }, 76 | { value: 'FO', label: 'Faroe Islands' }, 77 | { value: 'FJ', label: 'Fiji' }, 78 | { value: 'FI', label: 'Finland' }, 79 | { value: 'FR', label: 'France' }, 80 | { value: 'GF', label: 'French Guiana' }, 81 | { value: 'PF', label: 'French Polynesia' }, 82 | { value: 'TF', label: 'French Southern and Antarctic Lands' }, 83 | { value: 'GA', label: 'Gabon' }, 84 | { value: 'GM', label: 'The Gambia' }, 85 | { value: 'GE', label: 'Georgia' }, 86 | { value: 'DE', label: 'Germany' }, 87 | { value: 'GH', label: 'Ghana' }, 88 | { value: 'GI', label: 'Gibraltar' }, 89 | { value: 'GR', label: 'Greece' }, 90 | { value: 'GL', label: 'Greenland' }, 91 | { value: 'GD', label: 'Grenada' }, 92 | { value: 'GP', label: 'Guadeloupe' }, 93 | { value: 'GU', label: 'Guam' }, 94 | { value: 'GT', label: 'Guatemala' }, 95 | { value: 'GG', label: 'Guernsey' }, 96 | { value: 'GW', label: 'Guinea-Bissau' }, 97 | { value: 'GY', label: 'Guyana' }, 98 | { value: 'HT', label: 'Haiti' }, 99 | { value: 'HM', label: 'Heard Island and McDonald Islands' }, 100 | { value: 'VA', label: 'Holy See' }, 101 | { value: 'HN', label: 'Honduras' }, 102 | { value: 'HK', label: 'Hong Kong' }, 103 | { value: 'HU', label: 'Hungary' }, 104 | { value: 'IS', label: 'Iceland' }, 105 | { value: 'IN', label: 'India' }, 106 | { value: 'ID', label: 'Indonesia' }, 107 | { value: 'CI', label: 'Ivory Coast' }, 108 | { value: 'IR', label: 'Iran' }, 109 | { value: 'IQ', label: 'Iraq' }, 110 | { value: 'IE', label: 'Republic of Ireland' }, 111 | { value: 'IM', label: 'Isle of Man' }, 112 | { value: 'IL', label: 'Israel' }, 113 | { value: 'IT', label: 'Italy' }, 114 | { value: 'JM', label: 'Jamaica' }, 115 | { value: 'JP', label: 'Japan' }, 116 | { value: 'JE', label: 'Jersey' }, 117 | { value: 'JO', label: 'Jordan' }, 118 | { value: 'KZ', label: 'Kazakhstan' }, 119 | { value: 'KE', label: 'Kenya' }, 120 | { value: 'KI', label: 'Kiribati' }, 121 | { value: 'KW', label: 'Kuwait' }, 122 | { value: 'KG', label: 'Kyrgyzstan' }, 123 | { value: 'LA', label: 'Laos' }, 124 | { value: 'LV', label: 'Latvia' }, 125 | { value: 'LB', label: 'Lebanon' }, 126 | { value: 'LS', label: 'Lesotho' }, 127 | { value: 'LR', label: 'Liberia' }, 128 | { value: 'LY', label: 'Libya' }, 129 | { value: 'LI', label: 'Liechtenstein' }, 130 | { value: 'LT', label: 'Lithuania' }, 131 | { value: 'LU', label: 'Luxembourg' }, 132 | { value: 'MO', label: 'Macau' }, 133 | { value: 'MK', label: 'Republic of Macedonia' }, 134 | { value: 'MG', label: 'Madagascar' }, 135 | { value: 'MW', label: 'Malawi' }, 136 | { value: 'MY', label: 'Malaysia' }, 137 | { value: 'MV', label: 'Maldives' }, 138 | { value: 'ML', label: 'Mali' }, 139 | { value: 'MT', label: 'Malta' }, 140 | { value: 'MH', label: 'Marshall Islands' }, 141 | { value: 'MQ', label: 'Martinique' }, 142 | { value: 'MR', label: 'Mauritania' }, 143 | { value: 'MU', label: 'Mauritius' }, 144 | { value: 'YT', label: 'Mayotte' }, 145 | { value: 'MX', label: 'Mexico' }, 146 | { value: 'FM', label: 'Federated States of Micronesia' }, 147 | { value: 'MD', label: 'Moldova' }, 148 | { value: 'MC', label: 'Monaco' }, 149 | { value: 'MN', label: 'Mongolia' }, 150 | { value: 'ME', label: 'Montenegro' }, 151 | { value: 'MS', label: 'Montserrat' }, 152 | { value: 'MA', label: 'Morocco' }, 153 | { value: 'MZ', label: 'Mozambique' }, 154 | { value: 'MM', label: 'Myanmar' }, 155 | { value: 'NA', label: 'Namibia' }, 156 | { value: 'NR', label: 'Nauru' }, 157 | { value: 'NP', label: 'Nepal' }, 158 | { value: 'NL', label: 'Netherlands' }, 159 | { value: 'NC', label: 'New Caledonia' }, 160 | { value: 'NZ', label: 'New Zealand' }, 161 | { value: 'NI', label: 'Nicaragua' }, 162 | { value: 'NE', label: 'Niger' }, 163 | { value: 'NG', label: 'Nigeria' }, 164 | { value: 'NU', label: 'Niue' }, 165 | { value: 'NF', label: 'Norfolk Island' }, 166 | { value: 'KP', label: 'North Korea' }, 167 | { value: 'MP', label: 'Northern Mariana Islands' }, 168 | { value: 'NO', label: 'Norway' }, 169 | { value: 'OM', label: 'Oman' }, 170 | { value: 'PK', label: 'Pakistan' }, 171 | { value: 'PW', label: 'Palau' }, 172 | { value: 'PS', label: 'Palestine' }, 173 | { value: 'PA', label: 'Panama' }, 174 | { value: 'PG', label: 'Papua New Guinea' }, 175 | { value: 'PY', label: 'Paraguay' }, 176 | { value: 'PE', label: 'Peru' }, 177 | { value: 'PH', label: 'Philippines' }, 178 | { value: 'PN', label: 'Pitcairn Islands' }, 179 | { value: 'PL', label: 'Poland' }, 180 | { value: 'PT', label: 'Portugal' }, 181 | { value: 'PR', label: 'Puerto Rico' }, 182 | { value: 'QA', label: 'Qatar' }, 183 | { value: 'XK', label: 'Republic of Kosovo' }, 184 | { value: 'RE', label: 'Réunion' }, 185 | { value: 'RO', label: 'Romania' }, 186 | { value: 'RU', label: 'Russia' }, 187 | { value: 'RW', label: 'Rwanda' }, 188 | { value: 'BL', label: 'Saint Barthélemy' }, 189 | { value: 'SH', label: 'Saint Helena' }, 190 | { value: 'KN', label: 'Saint Kitts and Nevis' }, 191 | { value: 'LC', label: 'Saint Lucia' }, 192 | { value: 'MF', label: 'Saint Martin' }, 193 | { value: 'PM', label: 'Saint Pierre and Miquelon' }, 194 | { value: 'VC', label: 'Saint Vincent and the Grenadines' }, 195 | { value: 'WS', label: 'Samoa' }, 196 | { value: 'SM', label: 'San Marino' }, 197 | { value: 'ST', label: 'São Tomé and Príncipe' }, 198 | { value: 'SA', label: 'Saudi Arabia' }, 199 | { value: 'SN', label: 'Senegal' }, 200 | { value: 'RS', label: 'Serbia' }, 201 | { value: 'SC', label: 'Seychelles' }, 202 | { value: 'SL', label: 'Sierra Leone' }, 203 | { value: 'SG', label: 'Singapore' }, 204 | { value: 'SX', label: 'Sint Maarten' }, 205 | { value: 'SK', label: 'Slovakia' }, 206 | { value: 'SI', label: 'Slovenia' }, 207 | { value: 'SB', label: 'Solomon Islands' }, 208 | { value: 'SO', label: 'Somalia' }, 209 | { value: 'ZA', label: 'South Africa' }, 210 | { value: 'GS', label: 'South Georgia' }, 211 | { value: 'KR', label: 'South Korea' }, 212 | { value: 'SS', label: 'South Sudan' }, 213 | { value: 'ES', label: 'Spain' }, 214 | { value: 'LK', label: 'Sri Lanka' }, 215 | { value: 'SD', label: 'Sudan' }, 216 | { value: 'SR', label: 'Surinae' }, 217 | { value: 'SJ', label: 'Svalbard and Jan Mayen' }, 218 | { value: 'SZ', label: 'Swaziland' }, 219 | { value: 'SE', label: 'Sweden' }, 220 | { value: 'CH', label: 'Switzerland' }, 221 | { value: 'SY', label: 'Syria' }, 222 | { value: 'TW', label: 'Taiwan' }, 223 | { value: 'TJ', label: 'Tajikistan' }, 224 | { value: 'TZ', label: 'Tanzania' }, 225 | { value: 'TH', label: 'Thailand' }, 226 | { value: 'TL', label: 'East Timor' }, 227 | { value: 'TG', label: 'Togo' }, 228 | { value: 'TK', label: 'Tokelau' }, 229 | { value: 'TO', label: 'Tonga' }, 230 | { value: 'TT', label: 'Trinidad and Tobago' }, 231 | { value: 'TN', label: 'Tunisia' }, 232 | { value: 'TR', label: 'Turkey' }, 233 | { value: 'TM', label: 'Turkmenistan' }, 234 | { value: 'TC', label: 'Turks and Caicos Islands' }, 235 | { value: 'TV', label: 'Tuvalu' }, 236 | { value: 'UG', label: 'Uganda' }, 237 | { value: 'UA', label: 'Ukraine' }, 238 | { value: 'AE', label: 'United Arab Emirates' }, 239 | { value: 'GB', label: 'United Kingdom' }, 240 | { value: 'US', label: 'United States' }, 241 | { value: 'UY', label: 'Uruguay' }, 242 | { value: 'UZ', label: 'Uzbekistan' }, 243 | { value: 'VU', label: 'Vanuatu' }, 244 | { value: 'VE', label: 'Venezuela' }, 245 | { value: 'VN', label: 'Vietnam' }, 246 | { value: 'WF', label: 'Wallis and Futuna' }, 247 | { value: 'EH', label: 'Western Sahara' }, 248 | { value: 'YE', label: 'Yemen' }, 249 | { value: 'ZM', label: 'Zambia' }, 250 | { value: 'ZW', label: 'Zimbabwe' } 251 | ] 252 | -------------------------------------------------------------------------------- /app/plugins/core/settings/Settings/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { FormComponents } from '@cerebroapp/cerebro-ui' 4 | import themes from 'lib/themes' 5 | 6 | import Hotkey from './Hotkey' 7 | import countries from './countries' 8 | import styles from './styles.module.css' 9 | 10 | const { 11 | Select, Checkbox, Wrapper, Text 12 | } = FormComponents 13 | 14 | function Settings({ get, set }) { 15 | const [state, setState] = useState(() => ({ 16 | hotkey: get('hotkey'), 17 | showInTray: get('showInTray'), 18 | country: get('country'), 19 | theme: get('theme'), 20 | proxy: get('proxy'), 21 | developerMode: get('developerMode'), 22 | cleanOnHide: get('cleanOnHide'), 23 | selectOnShow: get('selectOnShow'), 24 | pluginsSettings: get('plugins'), 25 | openAtLogin: get('openAtLogin'), 26 | searchBarPlaceholder: get('searchBarPlaceholder') 27 | })) 28 | 29 | const changeConfig = (key, value) => { 30 | set(key, value) 31 | setState((prevState) => ({ ...prevState, [key]: value })) 32 | } 33 | 34 | return ( 35 |
36 | 37 | changeConfig('hotkey', key)} 40 | /> 41 | 42 | t.value === state.theme)} 52 | options={themes} 53 | onChange={(value) => changeConfig('theme', value)} 54 | /> 55 | changeConfig('proxy', value)} 60 | /> 61 | changeConfig('searchBarPlaceholder', value)} 66 | /> 67 | changeConfig('openAtLogin', value)} 71 | /> 72 | changeConfig('showInTray', value)} 76 | /> 77 | changeConfig('developerMode', value)} 81 | /> 82 | changeConfig('cleanOnHide', value)} 86 | /> 87 | changeConfig('selectOnShow', value)} 91 | /> 92 |
93 | ) 94 | } 95 | 96 | Settings.propTypes = { 97 | get: PropTypes.func.isRequired, 98 | set: PropTypes.func.isRequired 99 | } 100 | 101 | export default Settings 102 | -------------------------------------------------------------------------------- /app/plugins/core/settings/Settings/styles.module.css: -------------------------------------------------------------------------------- 1 | .settings { 2 | display: flex; 3 | align-self: flex-start; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | .label { 9 | margin-right: 15px; 10 | margin-top: 8px; 11 | min-width: 60px; 12 | max-width: 60px; 13 | } 14 | 15 | .checkbox { 16 | margin-right: 5px; 17 | } 18 | 19 | .settingItem { 20 | padding: 20px; 21 | box-sizing: border-box; 22 | width: 100%; 23 | border-color: #d9d9d9 #ccc #b3b3b3; 24 | border-top: 1px solid #ccc; 25 | margin-top: 16px; 26 | } 27 | 28 | .header { 29 | font-weight: bold; 30 | } 31 | 32 | .input { 33 | font-size: 16px; 34 | line-height: 34px; 35 | padding: 0 10px; 36 | box-sizing: border-box; 37 | width: 100%; 38 | border-color: #d9d9d9 #ccc #b3b3b3; 39 | border-radius: 4px; 40 | border: 1px solid #ccc; 41 | } 42 | -------------------------------------------------------------------------------- /app/plugins/core/settings/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { search } from 'cerebro-tools' 3 | import Settings from './Settings' 4 | import icon from '../icon.png' 5 | 6 | // Settings plugin name 7 | const NAME = 'Cerebro Settings' 8 | 9 | // Settings plugins in the end of list 10 | const order = 9 11 | 12 | // Phrases that used to find settings plugins 13 | const KEYWORDS = [ 14 | NAME, 15 | 'Cerebro Preferences', 16 | 'cfg', 17 | 'config', 18 | 'params' 19 | ] 20 | 21 | /** 22 | * Plugin to show app settings in results list 23 | * 24 | * @param {String} options.term 25 | * @param {Function} options.display 26 | */ 27 | const settingsPlugin = ({ 28 | term, display, config, actions 29 | }) => { 30 | const found = search(KEYWORDS, term).length > 0 31 | if (found) { 32 | const results = [{ 33 | order, 34 | icon, 35 | title: NAME, 36 | term: NAME, 37 | getPreview: () => ( 38 | config.set(key, value)} 40 | get={(key) => config.get(key)} 41 | /> 42 | ), 43 | onSelect: (event) => { 44 | event.preventDefault() 45 | actions.replaceTerm(NAME) 46 | } 47 | }] 48 | display(results) 49 | } 50 | } 51 | 52 | export default { 53 | name: NAME, 54 | fn: settingsPlugin 55 | } 56 | -------------------------------------------------------------------------------- /app/plugins/core/version/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { search } from 'cerebro-tools' 3 | import icon from '../icon.png' 4 | 5 | // Settings plugin name 6 | const NAME = 'Cerebro Version' 7 | 8 | // Settings plugins in the end of list 9 | const order = 9 10 | 11 | // Phrases that used to find settings plugins 12 | const KEYWORDS = [ 13 | NAME, 14 | 'ver', 15 | 'version' 16 | ] 17 | 18 | const { CEREBRO_VERSION } = process.env 19 | 20 | /** 21 | * Plugin to show app settings in results list 22 | * 23 | * @param {String} options.term 24 | * @param {Function} options.display 25 | */ 26 | const versionPlugin = ({ term, display, actions }) => { 27 | const found = search(KEYWORDS, term).length > 0 28 | 29 | if (found) { 30 | const results = [{ 31 | order, 32 | icon, 33 | title: NAME, 34 | term: NAME, 35 | getPreview: () => (
{CEREBRO_VERSION}
), 36 | onSelect: (event) => { 37 | event.preventDefault() 38 | actions.replaceTerm(NAME) 39 | } 40 | }] 41 | display(results) 42 | } 43 | } 44 | 45 | export default { name: NAME, fn: versionPlugin } 46 | -------------------------------------------------------------------------------- /app/plugins/externalPlugins.js: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash/debounce' 2 | import chokidar from 'chokidar' 3 | import path from 'path' 4 | import initPlugin from 'lib/initPlugin' 5 | import { modulesDirectory, ensureFiles, settings } from 'lib/plugins' 6 | 7 | const plugins = {} 8 | 9 | const requirePlugin = (pluginPath) => { 10 | try { 11 | let plugin = window.require(pluginPath) 12 | // Fallback for plugins with structure like `{default: {fn: ...}}` 13 | const keys = Object.keys(plugin) 14 | if (keys.length === 1 && keys[0] === 'default') { 15 | plugin = plugin.default 16 | } 17 | return plugin 18 | } catch (error) { 19 | // catch all errors from plugin loading 20 | console.log('Error requiring', pluginPath) 21 | console.log(error) 22 | } 23 | } 24 | 25 | /** 26 | * Validate plugin module signature 27 | * 28 | * @param {Object} plugin 29 | * @return {Boolean} 30 | */ 31 | const isPluginValid = (plugin) => ( 32 | plugin 33 | // Check existing of main plugin function 34 | && typeof plugin.fn === 'function' 35 | // Check that plugin function accepts 0 or 1 argument 36 | && plugin.fn.length <= 1 37 | ) 38 | 39 | ensureFiles() 40 | 41 | /* As we support scoped plugins, using 'base' as plugin name is no longer valid 42 | because it is not unique. '@example/plugin' and '@test/plugin' would both be treated as 'plugin' 43 | So now we must introduce the scope to the plugin name 44 | This function returns the name with the scope if it is present in the path 45 | */ 46 | const getPluginName = (pluginPath) => { 47 | const { base, dir } = path.parse(pluginPath) 48 | const scope = dir.match(/@.+$/) 49 | if (!scope) return base 50 | return `${scope[0]}/${base}` 51 | } 52 | 53 | const setupPluginsWatcher = () => { 54 | if (global.isBackground) return 55 | 56 | const pluginsWatcher = chokidar.watch(modulesDirectory, { depth: 1 }) 57 | pluginsWatcher.on('unlinkDir', (pluginPath) => { 58 | const { base, dir } = path.parse(pluginPath) 59 | if (base.match(/node_modules/) || base.match(/^@/)) return 60 | if (!dir.match(/node_modules$/) && !dir.match(/@.+$/)) return 61 | 62 | const pluginName = getPluginName(pluginPath) 63 | 64 | const requirePath = window.require.resolve(pluginPath) 65 | delete plugins[pluginName] 66 | delete window.require.cache[requirePath] 67 | console.log(`[${pluginName}] Plugin removed`) 68 | }) 69 | 70 | pluginsWatcher.on('addDir', (pluginPath) => { 71 | const { base, dir } = path.parse(pluginPath) 72 | 73 | if (base.match(/node_modules/) || base.match(/^@/)) return 74 | if (!dir.match(/node_modules$/) && !dir.match(/@.+$/)) return 75 | 76 | const pluginName = getPluginName(pluginPath) 77 | 78 | setTimeout(() => { 79 | console.group(`Load plugin: ${pluginName}`) 80 | console.log(`Path: ${pluginPath}...`) 81 | const plugin = requirePlugin(pluginPath) 82 | if (!isPluginValid(plugin)) { 83 | console.log('Plugin is not valid, skipped') 84 | console.groupEnd() 85 | return 86 | } 87 | if (!settings.validate(plugin)) { 88 | console.log('Invalid plugins settings') 89 | console.groupEnd() 90 | return 91 | } 92 | 93 | console.log('Loaded.') 94 | const requirePath = window.require.resolve(pluginPath) 95 | const watcher = chokidar.watch(pluginPath, { depth: 0 }) 96 | watcher.on('change', debounce(() => { 97 | console.log(`[${pluginName}] Update plugin`) 98 | delete window.require.cache[requirePath] 99 | plugins[pluginName] = window.require(pluginPath) 100 | console.log(`[${pluginName}] Plugin updated`) 101 | }, 1000)) 102 | plugins[pluginName] = plugin 103 | initPlugin(plugin, pluginName) 104 | console.groupEnd() 105 | }, 1000) 106 | }) 107 | } 108 | 109 | setupPluginsWatcher() 110 | 111 | export default plugins 112 | -------------------------------------------------------------------------------- /app/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import core from './core' 2 | import externalPlugins from './externalPlugins' 3 | 4 | const pluginsService = { 5 | corePlugins: core, 6 | allPlugins: Object.assign(externalPlugins, core), 7 | externalPlugins, 8 | } 9 | 10 | export default pluginsService 11 | -------------------------------------------------------------------------------- /app/tray_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/app/tray_icon.ico -------------------------------------------------------------------------------- /app/tray_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/app/tray_icon.png -------------------------------------------------------------------------------- /app/tray_iconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/app/tray_iconTemplate@2x.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-typescript', 4 | [ 5 | '@babel/preset-env', { 6 | /** Targets must match the versions supported by electron. 7 | * See https://www.electronjs.org/ 8 | */ 9 | targets: { 10 | node: '16', 11 | chrome: '102' 12 | } 13 | } 14 | ], 15 | '@babel/preset-react' 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icon.ico -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icon.png -------------------------------------------------------------------------------- /build/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icons/1024x1024.png -------------------------------------------------------------------------------- /build/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icons/128x128.png -------------------------------------------------------------------------------- /build/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icons/16x16.png -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icons/32x32.png -------------------------------------------------------------------------------- /build/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icons/48x48.png -------------------------------------------------------------------------------- /build/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/icons/512x512.png -------------------------------------------------------------------------------- /build/installer.nsh: -------------------------------------------------------------------------------- 1 | !macro customInstall 2 | DetailPrint "Register cerebro URI Handler" 3 | DeleteRegKey HKCR "cerebro" 4 | WriteRegStr HKCR "cerebro" "" "URL:cerebro" 5 | WriteRegStr HKCR "cerebro" "URL Protocol" "" 6 | WriteRegStr HKCR "cerebro\DefaultIcon" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME}" 7 | WriteRegStr HKCR "cerebro\shell" "" "" 8 | WriteRegStr HKCR "cerebro\shell\Open" "" "" 9 | WriteRegStr HKCR "cerebro\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1" 10 | !macroend 11 | -------------------------------------------------------------------------------- /build/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cerebroapp/cerebro/d6bd5f6b5a4decb3fce71ba09e2439e1ad036a24/build/logo.png -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "Cerebro", 3 | "appId": "com.cerebroapp.Cerebro", 4 | "protocols": { 5 | "name": "Cerebro URLs", 6 | "role": "Viewer", 7 | "schemes": [ 8 | "cerebro" 9 | ] 10 | }, 11 | "directories": { 12 | "app": "./app", 13 | "output": "release" 14 | }, 15 | "linux": { 16 | "target": [ 17 | { 18 | "target": "deb", 19 | "arch": [ 20 | "x64" 21 | ] 22 | }, 23 | { 24 | "target": "AppImage", 25 | "arch": [ 26 | "x64" 27 | ] 28 | } 29 | ], 30 | "category": "Utility" 31 | }, 32 | "mac": { 33 | "category": "public.app-category.productivity" 34 | }, 35 | "dmg": { 36 | "contents": [ 37 | { 38 | "x": 410, 39 | "y": 150, 40 | "type": "link", 41 | "path": "/Applications" 42 | }, 43 | { 44 | "x": 130, 45 | "y": 150, 46 | "type": "file" 47 | } 48 | ] 49 | }, 50 | "win": { 51 | "target": [ 52 | { 53 | "target": "nsis", 54 | "arch": [ 55 | "x64", 56 | "ia32" 57 | ] 58 | }, 59 | { 60 | "target": "portable", 61 | "arch": [ 62 | "x64", 63 | "ia32" 64 | ] 65 | } 66 | ] 67 | }, 68 | "nsis": { 69 | "include": "build/installer.nsh", 70 | "perMachine": true 71 | }, 72 | "files": [ 73 | "dist/", 74 | "main/index.html", 75 | "main/css,", 76 | "background/index.html", 77 | "tray_icon.png", 78 | "tray_icon.ico", 79 | "tray_iconTemplate@2x.png", 80 | "node_modules/", 81 | "app/node_modules/", 82 | "main.js", 83 | "main.js.map", 84 | "package.json", 85 | "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,test,__tests__,tests,powered-test,example,examples,*.d.ts}", 86 | "!**/node_modules/.bin", 87 | "!**/*.{o,hprof,orig,pyc,pyo,rbc}", 88 | "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,thumbs.db,.gitignore,.gitattributes,.editorconfig,.flowconfig,.yarn-metadata.json,.idea,appveyor.yml,.travis.yml,circle.yml,npm-debug.log,.nyc_output,yarn.lock,.yarn-integrity}" 89 | ], 90 | "squirrelWindows": { 91 | "iconUrl": "https://raw.githubusercontent.com/cerebroapp/cerebro/master/build/icon.ico" 92 | }, 93 | "publish": { 94 | "provider": "github", 95 | "vPrefixedTagName": true, 96 | "releaseType": "release" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | moduleDirectories: ['node_modules', 'app'], 4 | moduleNameMapper: { 5 | '\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/__mocks__/fileMock.js', 6 | '\\.(css|less)$': '/__mocks__/fileMock.js' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cerebro", 3 | "productName": "cerebro", 4 | "version": "0.11.0", 5 | "description": "Cerebro is an open-source launcher to improve your productivity and efficiency", 6 | "main": "./app/main.js", 7 | "scripts": { 8 | "test": "cross-env NODE_ENV=test CEREBRO_DATA_PATH=userdata jest", 9 | "test-watch": "jest -- --watch", 10 | "lint": "eslint app/background app/lib app/main app/plugins *.js", 11 | "hot-server": "node -r @babel/register server.js", 12 | "build-main": "webpack --mode production --config webpack.config.electron.js", 13 | "build-main-dev": "webpack --mode development --config webpack.config.electron.js", 14 | "build-renderer": "webpack --config webpack.config.production.js", 15 | "bundle-analyze": "cross-env ANALYZE=true node ./node_modules/webpack/bin/webpack --config webpack.config.production.js && open ./app/dist/stats.html", 16 | "build": "run-p build-main build-renderer", 17 | "start": "cross-env NODE_ENV=production electron ./app", 18 | "start-hot": "yarn build-main-dev && cross-env NODE_ENV=development electron ./app", 19 | "release": "build -mwl --draft", 20 | "dev": "run-p hot-server start-hot", 21 | "postinstall": "electron-builder install-app-deps", 22 | "package": "yarn build && npx electron-builder", 23 | "prepare": "husky install", 24 | "commit": "cz" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/cerebroapp/cerebro.git" 29 | }, 30 | "author": { 31 | "name": "CerebroApp Organization", 32 | "email": "kelionweb@gmail.com", 33 | "url": "https://github.com/cerebroapp" 34 | }, 35 | "contributors": [ 36 | "Alexandr Subbotin (https://github.com/KELiON)", 37 | "Gustavo Pereira =16.x" 97 | }, 98 | "config": { 99 | "commitizen": { 100 | "path": "./node_modules/cz-conventional-changelog" 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-nested': {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const webpack = require('webpack') 3 | const webpackDevMiddleware = require('webpack-dev-middleware') 4 | const webpackHotMiddleware = require('webpack-hot-middleware') 5 | 6 | const config = require('./webpack.config.development') 7 | 8 | const app = express() 9 | const compiler = webpack(config) 10 | const PORT = 3000 11 | 12 | const wdm = webpackDevMiddleware(compiler) 13 | 14 | app.use(wdm) 15 | 16 | app.use(webpackHotMiddleware(compiler)) 17 | 18 | const server = app.listen(PORT, 'localhost', (err) => { 19 | if (err) { 20 | console.error(err) 21 | return 22 | } 23 | 24 | console.log(`Listening at http://localhost:${PORT}`) 25 | }) 26 | 27 | process.on('SIGTERM', () => { 28 | console.log('Stopping dev server') 29 | wdm.close() 30 | server.close(() => { 31 | process.exit(0) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "app", 4 | "jsx": "react", 5 | "allowJs": true, 6 | "noImplicitAny": true, 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | }, 10 | "include": ["./app"] 11 | } 12 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const CopyWebpackPlugin = require('copy-webpack-plugin') 3 | const LodashModuleReplacementPlugin = require('lodash-webpack-plugin') 4 | 5 | module.exports = { 6 | module: { 7 | rules: [{ 8 | test: /\.(js|ts)x?$/, 9 | use: 'babel-loader', 10 | exclude: /node_modules/ 11 | }, { 12 | test: /\.jpe?g$|\.gif$|\.png$|\.svg$|\.woff$|\.ttf$|\.wav$|\.mp3$/, 13 | type: 'asset/inline' 14 | }] 15 | }, 16 | output: { 17 | path: path.join(__dirname, 'app'), 18 | filename: '[name].bundle.js', 19 | libraryTarget: 'commonjs2' 20 | }, 21 | resolve: { 22 | modules: [ 23 | path.join(__dirname, 'app'), 24 | 'node_modules' 25 | ], 26 | extensions: ['.ts', '.js', '.tsx', '.jsx'], 27 | }, 28 | plugins: [ 29 | new LodashModuleReplacementPlugin(), 30 | new CopyWebpackPlugin({ 31 | patterns: [{ 32 | from: 'app/main/css/themes/*', 33 | to: './main/css/themes/[name][ext]' 34 | }] 35 | }) 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /webpack.config.development.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const baseConfig = require('./webpack.config.base') 3 | 4 | const config = { 5 | ...baseConfig, 6 | mode: 'development', 7 | devtool: 'inline-source-map', 8 | entry: { 9 | background: [ 10 | 'webpack-hot-middleware/client?path=http://localhost:3000/__webpack_hmr', 11 | './app/background/background', 12 | ], 13 | main: [ 14 | 'webpack-hot-middleware/client?path=http://localhost:3000/__webpack_hmr', 15 | './app/main/main', 16 | ] 17 | }, 18 | output: { 19 | ...baseConfig.output, 20 | publicPath: 'http://localhost:3000/dist/' 21 | }, 22 | module: { 23 | ...baseConfig.module, 24 | rules: [ 25 | ...baseConfig.module.rules, 26 | { 27 | test: /\.css$/, 28 | use: [ 29 | 'style-loader', 30 | { 31 | loader: 'css-loader', 32 | options: { 33 | modules: true, 34 | sourceMap: true, 35 | importLoaders: 1, 36 | }, 37 | }, 38 | 'postcss-loader', 39 | ], 40 | include: /\.module\.s?(c|a)ss$/, 41 | }, 42 | { 43 | test: /\.css$/, 44 | use: ['style-loader', 'css-loader', 'postcss-loader'], 45 | exclude: /\.module\.css$/, 46 | }, 47 | ] 48 | }, 49 | plugins: [ 50 | ...baseConfig.plugins, 51 | new webpack.LoaderOptionsPlugin({ 52 | debug: true 53 | }), 54 | new webpack.HotModuleReplacementPlugin(), 55 | ], 56 | stats: { 57 | colors: true, 58 | }, 59 | target: 'electron-renderer' 60 | } 61 | 62 | module.exports = config 63 | -------------------------------------------------------------------------------- /webpack.config.electron.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.config.base') 2 | 3 | module.exports = { 4 | ...baseConfig, 5 | module: { 6 | rules: [{ 7 | test: /\.(js|ts)x?$/, 8 | exclude: /node_modules/, 9 | use: ['babel-loader'] 10 | }] 11 | }, 12 | devtool: 'source-map', 13 | entry: './app/main.development', 14 | output: { 15 | ...baseConfig.output, 16 | filename: './main.js' 17 | }, 18 | target: 'electron-main' 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.production.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 3 | const Visualizer = require('webpack-visualizer-plugin') 4 | const baseConfig = require('./webpack.config.base') 5 | 6 | const config = { 7 | ...baseConfig, 8 | mode: 'production', 9 | devtool: 'source-map', 10 | entry: { 11 | main: './app/main/main', 12 | background: './app/background/background' 13 | }, 14 | output: { 15 | ...baseConfig.output, 16 | path: path.join(__dirname, 'app', 'dist'), 17 | publicPath: '../dist/' 18 | }, 19 | module: { 20 | ...baseConfig.module, 21 | rules: [ 22 | ...baseConfig.module.rules, 23 | 24 | { 25 | test: /\.css$/, 26 | use: [ 27 | { loader: MiniCssExtractPlugin.loader }, 28 | { 29 | loader: 'css-loader', 30 | options: { 31 | modules: true, 32 | sourceMap: true, 33 | importLoaders: 1, 34 | }, 35 | }, 36 | 'postcss-loader', 37 | ], 38 | include: /\.module\.css$/, 39 | }, 40 | { 41 | test: /\.css$/, 42 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'], 43 | exclude: /\.module\.css$/, 44 | }, 45 | ] 46 | }, 47 | plugins: [ 48 | ...baseConfig.plugins, 49 | new MiniCssExtractPlugin() 50 | ], 51 | target: 'electron-renderer' 52 | } 53 | 54 | if (process.env.ANALYZE) { 55 | config.plugins.push(new Visualizer()) 56 | } 57 | 58 | module.exports = config 59 | --------------------------------------------------------------------------------