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

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

{format.description(description)}

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