Repository: RafalWilinski/s3-uploader Branch: master Commit: a0d81fe669a4 Files: 24 Total size: 39.7 KB Directory structure: gitextract_gearmds4/ ├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── ConfigurationService.js ├── README.md ├── S3Service.js ├── app/ │ ├── IpcRendererService.js │ ├── components/ │ │ ├── AccessForm.jsx │ │ ├── SettingsMenu.jsx │ │ └── StatusMenu.jsx │ ├── containers/ │ │ └── app.jsx │ ├── index.jsx │ └── styles/ │ ├── base/ │ │ ├── _animations.scss │ │ ├── _main.scss │ │ └── _typography.scss │ ├── components/ │ │ ├── _accessForm.scss │ │ ├── _settingsMenu.scss │ │ └── _statusMenu.scss │ └── main.scss ├── index.html ├── main.js ├── package.json └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": ["es2015", "react"] } ================================================ FILE: .eslintignore ================================================ public/ node_modules/ webpack.config.js ================================================ FILE: .eslintrc ================================================ { "extends": "airbnb-base", "rules": { "import/no-extraneous-dependencies": 0, "import/no-unresolved": 0, "no-case-declarations": 0, "no-param-reassign": 0, "consistent-return": 0 } } ================================================ FILE: .gitignore ================================================ node_modules/ .idea public/ s3-uploader-darwin-x64/ *.log .awscredentials.json .DS_Store ================================================ FILE: ConfigurationService.js ================================================ const fs = require('fs'); /** * Default file path where credentials will be stored. * @type {string} */ const configFilePath = __dirname + '/.awscredentials.json'; /** * Encoding used while reading persistent storage. * @type {string} */ const defaultEncoding = 'utf-8'; /** * Initial Configuration. * @type {{accessKey: string, secretKey: string, bucket: string, ACL: string, storageClass: string, * encryption: boolean}} */ const configuration = { accessKey: '', secretKey: '', bucket: '', folder: '', ACL: '', storageClass: '', encryption: false, }; /** * Merges old config with provided one. Saves changes to persistent storage. * @param newConfig */ const updateConfig = (newConfig) => { Object.assign(configuration, newConfig); fs.writeFile(configFilePath, JSON.stringify(configuration), {}, (err) => { if (err) throw new Error(err); }); }; /** * Loads configuration from persistent storage if it's possible. */ const loadConfig = () => new Promise((resolve, reject) => { fs.readFile(configFilePath, defaultEncoding, (err, data) => { if (err) { return reject(err); } Object.assign(configuration, JSON.parse(data)); return resolve(configuration); }); }); /** * Returns value for given key from configuration directory. Fast, in-memory function (fs not used). * @param key */ const getItem = (key) => configuration[key]; module.exports = { getItem, updateConfig, loadConfig, }; ================================================ FILE: README.md ================================================ ## aws-s3-uploader Simple macOS app for uploading files to Amazon Web Services. Just drag & drop files you'd like to upload on bucket icon displayed on your status bar or application window. ![Upload Animation](/upload_anim.gif?raw=true "Upload Anim") #### Configuration Simply click on bucket and follow instructions. You'll need access key and secret key for AWS user with S3 Exec Role. You can also try modifying `.awscredentials.json` file on your own. #### Features - logging in to AWS S3 account with access + secret keys - upload files by dragging them on status bar icon and on window - settings permissions and storage classes for uploads - get S3 link by clicking uploaded file from list - support for dropping many files at once and directories #### Todo - [x] Automatic login on start (no need to enter credentials with every start) - [ ] Add possibility to abort uploads - [ ] Tests! - [ ] Packages for distribution - [ ] Possibility to disable notifications & automatic URL-to-clipboard write #### Credits Special thanks to [parkjisun](https://thenounproject.com/naripuru/), [Sergey Furtaev](https://thenounproject.com/furtaev/), [Timothy Miller](https://thenounproject.com/tmthymllr/), [Joe Mortell](https://thenounproject.com/JoeMortell/) for Icons from [nounproject.com](https://thenounproject.com/) ================================================ FILE: S3Service.js ================================================ const AWS = require('aws-sdk'); const EventEmitter = require('events'); const fileType = require('file-type'); const configService = require('./ConfigurationService'); /** * High-level wrapper for AWS.S3 API with Promises instead of callbacks. */ class S3Service { /** * Constructor, creates S3Service object with AWS.S3 property. * * Constructor does not validate credentials validity. * @param accessKey * @param secretKey */ constructor(accessKey, secretKey) { this.accessKey = accessKey; this.secretKey = secretKey; AWS.config.update({ credentials: new AWS.Credentials({ accessKeyId: accessKey, secretAccessKey: secretKey, }), }); this.s3 = new AWS.S3(); } /** * AWS.S3.listBuckets wrapper. * @returns {Promise} */ getBuckets() { return new Promise((resolve, reject) => { this.s3.listBuckets((err, data) => { if (err) { return reject(err); } return resolve(data); }); }); } /** * AWS.S3.upload wrapper with some values preloaded from local configuration. * Returns EventEmitter emiting following events: * - error * - progress * - success * @returns {EventEmitter} */ uploadFile(fileName, data) { const uploadEventEmitter = new EventEmitter(); const mimeType = fileType(data); this.s3.upload({ Body: data, ContentType: (mimeType) ? mimeType.mime : 'application/octet-stream', ACL: configService.getItem('ACL'), Key: configService.getItem('folder') + '/' + fileName, StorageClass: configService.getItem('storageClass'), Bucket: configService.getItem('bucket'), }, (err, payload) => { if (err) uploadEventEmitter.emit('error', err); uploadEventEmitter.emit('success', payload); }).on('httpUploadProgress', (progress) => { uploadEventEmitter.emit('progress', progress); }); return uploadEventEmitter; } } module.exports = S3Service; ================================================ FILE: app/IpcRendererService.js ================================================ const { ipcRenderer } = require('electron'); /** * IPC Renderer Service * * Service for communicating renderer process with main Electron process. */ /** * Send action via IPC to request S3 Buckets from AWS using provided credentials. * @param accessKey * @param secretKey */ const requestBuckets = (accessKey, secretKey) => new Promise((resolve, reject) => { ipcRenderer.send('GET_BUCKETS', { accessKey, secretKey, }); ipcRenderer.on('GET_BUCKETS_REPLY', (event, arg) => { if (arg.success) return resolve(arg.data); return reject(arg.error); }); }); /** * Sends files dropped on browser window to main process. * @param files */ const sendDroppedFiles = (files) => { // FileList is not an array - forEach & map are not allowed const newFiles = []; for (let i = 0; i < files.length; i++) { newFiles.push(files[i].path); } ipcRenderer.send('DROP_FILES', { files: newFiles, }); }; /** * Send action via IPC to save configuration. * @param config */ const saveConfig = (config) => { ipcRenderer.send('UPDATE_CONFIG', config); }; /** * Procedure which forwards upload events from IPC bus to renderer process callbacks. * @param errorCallback * @param successCallback * @param progressCallback * @param startCallback */ const subscribeUploadEvents = (errorCallback, successCallback, progressCallback, startCallback) => { ipcRenderer.on('UPLOAD_SUCCESS', (event, arg) => { successCallback(arg); }); ipcRenderer.on('UPLOAD_ERROR', (event, arg) => { errorCallback(arg); }); ipcRenderer.on('UPLOAD_PROGRESS', (event, arg) => { progressCallback(arg); }); ipcRenderer.on('UPLOAD_START', (event, arg) => { startCallback(arg); }); }; module.exports = { subscribeUploadEvents, requestBuckets, saveConfig, sendDroppedFiles, }; ================================================ FILE: app/components/AccessForm.jsx ================================================ import React from 'react'; import '../styles/base/_animations.scss'; /** * Presentational Component for displaying 'login form'. * * Handles validation and passes data to higher-order components/containers. */ class AccessForm extends React.Component { /** * Constructor, sets default state and binds context to used functions. * @param props */ constructor(props) { super(props); this.state = { accessKey: '', secretKey: '', }; this.handleSubmit = this._handleSubmit.bind(this); this.handleAccessKeyChange = this._handleAccessKeyChange.bind(this); this.handleSecretKeyChange = this._handleSecretKeyChange.bind(this); }; /** * React built-in function. * * After mounting component, tries to automatically fill out form with saved credentials. */ componentDidMount() { const savedAccessKey = window.localStorage.getItem('accessKey') || ''; const savedSecretKey = window.localStorage.getItem('secretKey') || ''; document.getElementById('access_key_input').value = savedAccessKey; document.getElementById('secret_key_input').value = savedSecretKey; this.setState({ accessKey: savedAccessKey, secretKey: savedSecretKey, }); } /** * Procedure called when submitting form. * * Trims input, handles it and adds some fancy animations if something is wrong. * @param event * @private */ _handleSubmit(event) { event.preventDefault(); const accessKey = this.state.accessKey.trim(); const secretKey = this.state.secretKey.trim(); if (accessKey !== '' && secretKey !== '') { this.props.onCredentialsSubmitted(accessKey, secretKey); } else { if (accessKey === '') { document.getElementById('access_key_input').focus(); document.getElementById('access_key_input').className = 'animated shake'; } else if (secretKey === '') { document.getElementById('secret_key_input').focus(); document.getElementById('secret_key_input').className = 'animated shake'; } // Diminish animation after it ends. setTimeout(() => { document.getElementById('access_key_input').className = ''; document.getElementById('secret_key_input').className = ''; }, 1500); } }; /** * Changes this.state when user types something to access key field. * @param event * @private */ _handleAccessKeyChange(event) { this.setState({ accessKey: event.target.value }); }; /** * Changes this.state when user types something to secret key field. * @param event * @private */ _handleSecretKeyChange(event) { this.setState({ secretKey: event.target.value }); }; /** * React built-in function. * @returns {XML} */ render() { return (

In order to access S3, please provide AWS Access Key and Secret Key of user with sufficient permissions

{ this.props.error !== null ?

{this.props.error.message}

: '' }
); }; } AccessForm.propTypes = { // Function forwarded from app container, called after validation onCredentialsSubmitted: React.PropTypes.func.isRequired, // Error data returned from container error: React.PropTypes.object.isRequired, }; export default AccessForm; ================================================ FILE: app/components/SettingsMenu.jsx ================================================ import React from 'react'; /** * List of available storage options in AWS S3. * @type {*[]} */ const storageOptions = [ { displayName: 'Standard', id: 'STANDARD', }, { displayName: 'Reduced Redundancy', id: 'REDUCED_REDUNDANCY', }, { displayName: 'Infrequent Access', id: 'STANDARD_IA', }, ]; /** * List of available ACL policies in AWS S3. * @type {*[]} */ const permissionsOptions = [ { displayName: 'Private', id: 'private', }, { displayName: 'Public Read', id: 'public-read', }, { displayName: 'Public Read/Write', id: 'public-read-write', } ]; class SettingsMenu extends React.Component { /** * Constructor, sets default state and binds context to used functions. * @param props */ constructor(props) { super(props); this.state = { storageClass: '', ACL: '', encryption: '', bucket: '', folder: '', }; this.storageOptionChange = this._storageOptionChange.bind(this); this.permissionOptionChange = this._permissionOptionChange.bind(this); this.encryptionOptionChange = this._encryptionOptionChange.bind(this); this.changeDefault = this._changeDefault.bind(this); this.checkSelection = this._checkSelection.bind(this); this.folderChange = this._changeFolder.bind(this); } /** * Changes this.state when user selects new default bucket. * @param bucket * @private */ _changeDefault(bucket) { this.setState({ bucket, }); } /** * Changes this.folder with user supplied folder path * @param folder * @private */ _changeFolder(folder) { this.setState({ folder, }); } /** * Checks if selected item is currently selected. * @param item * @param selection * @param defaultClasses * @private */ _checkSelection(item, selection, defaultClasses = '') { if(item === selection) { return 'selected ' + defaultClasses; } else { return defaultClasses; } } /** * Changes this.state when user changes storage class. * @param event * @param storageClass * @private */ _storageOptionChange(event, storageClass) { event.preventDefault(); this.setState({ storageClass, }); } /** * Changes this.state when user changes default upload permissions * @param event * @param ACL * @private */ _permissionOptionChange(event, ACL) { event.preventDefault(); this.setState({ ACL, }); } /** * Changes this.state when user toggles encryption * @param event * @private */ _encryptionOptionChange(event) { this.setState({ encryption: event.target.checked ? 'AES256' : '', }); } /** * React built-in function * @returns {XML} */ render() { return (

Folder

this.folderChange(e.target.value)}>

Storage Class

{ storageOptions.map((option, index) => ) }

Default Permissions

{ permissionsOptions.map((option, index) => ) }
); } } SettingsMenu.propTypes = { // Function forwarded from app container, to be called when confirming settings onSettingsSelected: React.PropTypes.func.isRequired, // List of buckets returned from S3Service buckets: React.PropTypes.array.isRequired, }; export default SettingsMenu; ================================================ FILE: app/components/StatusMenu.jsx ================================================ import React from 'react'; const { clipboard } = require('electron'); /** * Changes clipboard contents to file URL from AWS S3 if file was uploaded successfully. * @param file */ const saveLinkToClipboard = (file) => { if (file.status === 'uploaded' && file.url !== '') { clipboard.writeText(file.url); } else { console.warn('File has been not uploaded yet!'); } }; /** * Presentational component showing all processed files statuses and current mode. */ const StatusMenu = (props) => (
{props.bucket} Folder: {props.folder} Permissions: {props.ACL} Storage Class: {props.storageClass}
props.resetSettings()}>
{ props.files.length === 0 ?
No files were uploaded yet.
: }
); StatusMenu.propTypes = { // Current permissions e.g. public-read-write ACL: React.PropTypes.string.isRequired, // Bucket where files fill be uploaded bucket: React.PropTypes.string.isRequired, // Folder (Prefix) to where the file will be uploaded. folder: React.PropTypes.string.isRequired, // Array of all files (uploaded, failed, in progress) files: React.PropTypes.arrayOf(React.PropTypes.shape({ // Filename key: React.PropTypes.string.isRequired, // Absolute path to file in local filesystem path: React.PropTypes.string.isRequired, // File status status: React.PropTypes.oneOf(['uploaded', 'uploading', 'failed']), // URL to file stored in AWS S3 service, will be set to '' if file is not uploaded url: React.PropTypes.string.isRequired, }).isRequired).isRequired, // Current storage class mode e.g. STANDARD_IA (Infrequent Access) storageClass: React.PropTypes.string.isRequired, // Function invoked when user wants to reset settings resetSettings: React.PropTypes.func.isRequired, }; export default StatusMenu; ================================================ FILE: app/containers/app.jsx ================================================ import React from 'react'; import AccessForm from '../components/AccessForm.jsx'; import StatusMenu from '../components/StatusMenu.jsx'; import SettingsMenu from '../components/SettingsMenu.jsx'; import IpcService from '../IpcRendererService'; import '../styles/main.scss'; /** * Main Application react container responsible for front-end logic. * Subscribes to IPCRendererService for main process events, displays presentational components, saves credentials and more. * Due to lack of Redux, every information is stored in this.state. */ class Application extends React.Component { /** * constructor * @param {object} props */ constructor(props) { super(props); this.state = { // Default permissions e.g. public-read-write ACL: window.localStorage.getItem('ACL') || false, // Default bucket name bucket: window.localStorage.getItem('bucket') || '', // Default folder name folder: window.localStorage.getItem('folder') || '', // Available buckets array buckets: [], // List of files which are being uploaded and were already uploaded files: [], // Tells whether user has entered correct AWS credentials isLoggedIn: window.localStorage.getItem('isSetupCorrectly') || false, // Set to true during S3.listBuckets isLoading: false, // Set to true after confirming settings in StatusMenu component isSettingsSet: window.localStorage.getItem('isSetupCorrectly') || false, // Contains error information if applicable loginError: {}, // Default storage class, e.g. REDUCED_REDUNDANCY storageClass: window.localStorage.getItem('storageClass') || '', }; this.bucketsLoaded = this._bucketsLoaded.bind(this); this.credentialsSubmitted = this._credentialsSubmitted.bind(this); this.getMenu = this._getMenu.bind(this); this.resetSettings = this._resetSettings.bind(this); this.settingsSet = this._settingsSet.bind(this); this.startLoading = this._startLoading.bind(this); this.uploadStarted = this._uploadStarted.bind(this); this.uploadFailed = this._uploadFailed.bind(this); this.uploadSucceeded = this._uploadSucceeded.bind(this); this.uploadProgressed = this._uploadProgressed.bind(this); /** * Subscribe for events from Electron main process related with uploading files. */ IpcService.subscribeUploadEvents( this.uploadFailed, this.uploadSucceeded, this.uploadProgressed, this.uploadStarted ); /** * Add possibility to drag files on window and prevent loading content of it. */ window.addEventListener('dragover', (event) => { event.preventDefault(); }); window.addEventListener('drop', (event) => { event.preventDefault(); IpcService.sendDroppedFiles(event.dataTransfer.files); }); } /** * Procedure fired when bucket list is fetched using AWS SDK. * @param payload * @private */ _bucketsLoaded(payload) { this.setState({ buckets: payload.Buckets, isLoggedIn: true, loginError: {}, isLoading: false, }); } /** * Procedure fired when user enters credentials in AccessForm component. Calls AWS.S3.listBuckets * function with provided keys. * @param accessKey * @param secretKey * @private */ _credentialsSubmitted(accessKey, secretKey) { this.startLoading(); IpcService.requestBuckets(accessKey, secretKey) .then((data) => { this.bucketsLoaded(data); window.localStorage.setItem('accessKey', accessKey); window.localStorage.setItem('secretKey', secretKey); }) .catch((loginError) => { this.setState({ loginError, isLoggedIn: false, isLoading: false, }) }); } /** * Rendering helper function, returns presentational component suitable for current app status. * E.g. After entering credentials and before S3 API response, loading indicator is displayed. * @returns {XML} * @private */ _getMenu() { if (this.state.isLoading) { return
; } if (this.state.isLoggedIn) { if (this.state.isSettingsSet) { return ; } else { return ; } } else { return } } _resetSettings() { window.localStorage.setItem('isSetupCorrectly', false); this.setState({ isLoggedIn: false, isSettingsSet: false, }); } /** * Procedure fired after saving settings in SettingsMenu presentational component. * Saves configuration in localStorage and current state. Also sends this information to main * electron process. * * @param settings * @private */ _settingsSet(settings) { window.localStorage.setItem('storageClass', settings.storageClass); window.localStorage.setItem('ACL', settings.ACL); window.localStorage.setItem('encryption', settings.encryption); window.localStorage.setItem('bucket', settings.bucket); window.localStorage.setItem('folder', settings.folder); window.localStorage.setItem('isSetupCorrectly', true); IpcService.saveConfig({ ACL: settings.ACL, storageClass: settings.storageClass, encryption: settings.encryption, bucket: settings.bucket, folder: settings.folder, }); this.setState({ ACL: settings.ACL, bucket: settings.bucket, folder: settings.folder, isSettingsSet: true, storageClass: settings.storageClass, }); } /** * Shows loading indicator * @private */ _startLoading() { this.setState({ isLoading: true }); } // TODO: Add directories support and abort function /** * Procedure fired when Electron main process notifies renderer process via IPC about upload process initiation. * Appends this.state.files with dropped files. * Contains file path dropped on icon. * * @param file * @private */ _uploadStarted(file) { const files = this.state.files; const folder = this.state.folder; files.push({ path: file.data, key: folder + '/' + file.data.split('/').pop(), status: 'uploading', url: '', }); this.setState({ files }); } /** * Procedure fired when Electron main process notifies renderer process via IPC about error during upload. * Contains error information. * * @param error * @private */ _uploadFailed(error) { const newFiles = this.state.files; let updated = this.state.files.find((file) => file.key === error.data.Key); updated.status = 'failed'; updated.error = error; this.setState({ files: newFiles, }); } /** * Procedure fired when Electron main process notifies renderer process via IPC about successful upload. * @param data * @private */ _uploadSucceeded(data) { const newFiles = this.state.files; let updated = this.state.files.find((file) => file.key === data.data.Key); updated.url = data.data.Location; updated.status = 'uploaded'; this.setState({ files: newFiles.reverse(), }); } /** * Procedure fired when Electron main process notifies renderer process via IPC about progression in uploading a file(s). * @param progress * @private */ _uploadProgressed(progress) { } /** * React built-in function * @returns {XML} */ render() { return (
{this.getMenu()}
); } } export default Application; ================================================ FILE: app/index.jsx ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import Application from './containers/app.jsx'; /** * Renders Application Container into DOM */ ReactDOM.render(, document.getElementById('app')); ================================================ FILE: app/styles/base/_animations.scss ================================================ // Wrong input animation @keyframes shake { from, to { transform: translate3d(0, 0, 0); } 10%, 30%, 50%, 70%, 90% { transform: translate3d(-10px, 0, 0); } 20%, 40%, 60%, 80% { transform: translate3d(10px, 0, 0); } } .shake { animation-name: shake; } .animated { animation-duration: 1s; animation-fill-mode: both; } .animated.infinite { animation-iteration-count: infinite; } .animated.hinge { animation-duration: 2s; } .animated.flipOutX, .animated.flipOutY, .animated.bounceIn, .animated.bounceOut { animation-duration: .75s; } // Loading Animation $boxSize: 15; $color: #dfdedd; $color2: #4f4d49; .spin-box { position: absolute; margin: auto; left: 0; top: 0; bottom: 0; right: 0; border-radius: 100%; width: $boxSize * 1px; height: $boxSize * 1px; box-shadow: $boxSize*1px $boxSize*1px $color2, $boxSize*-1px $boxSize*1px $color, $boxSize*-1px $boxSize*-1px $color2, $boxSize*1px $boxSize*-1px $color; animation: spin ease infinite 4s; } @keyframes spin { 0%, 100% { box-shadow: $boxSize*1px $boxSize*1px $color2, $boxSize*-1px $boxSize*1px $color, $boxSize*-1px $boxSize*-1px $color2, $boxSize*1px $boxSize*-1px $color; } 25% { box-shadow: $boxSize*-1px $boxSize*1px $color, $boxSize*-1px $boxSize*-1px $color2, $boxSize*1px $boxSize*-1px $color, $boxSize*1px $boxSize*1px $color2; } 50% { box-shadow: $boxSize*-1px $boxSize*-1px $color2, $boxSize*1px $boxSize*-1px $color, $boxSize*1px $boxSize*1px $color2, $boxSize*-1px $boxSize*1px $color; } 75% { box-shadow: $boxSize*1px $boxSize*-1px $color, $boxSize*1px $boxSize*1px $color2, $boxSize*-1px $boxSize*1px $color, $boxSize*-1px $boxSize*-1px $color2; } } ================================================ FILE: app/styles/base/_main.scss ================================================ $white-color: #EAEAEA; $grey-color: #b4b4b4; $black-color: #424242; $green-color: #2dc83a; html { overflow-x: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } html, body, #app { height: 100%; margin: 0; } button { background: none; border: none; color: #FFFFFF; font-size: 1.2em; font-weight: 400; } ::-webkit-scrollbar { background: transparent; width: 0; } *:focus { outline: none; } .white { color: $white-color; } .extra-margin { margin: 10px; } ================================================ FILE: app/styles/base/_typography.scss ================================================ @import './_main.scss'; p, span, form, input, button { color: $black-color; font-family: "Futura-Medium", sans-serif; font-size: 10px; letter-spacing: -0.30px; line-height: 1; margin-top: 0; text-transform: none; text-align: center; } .big { font-size: 14px; } ================================================ FILE: app/styles/components/_accessForm.scss ================================================ @import '../base/_main.scss'; .access-form-container { display: flex; flex-direction: column; align-content: center; align-items: center; justify-content: center; height: 90vh; form { display: flex; flex-direction: column; align-content: center; align-items: center; justify-content: center; margin: 20px; input { width: 200px; border-width: 0 0 1px 0; } #submitButton { margin-top: 10px; border-radius: 50px; font-family: "Montserrat", sans-serif; font-size: 1.2em; font-weight: 700; padding: 10px 40px 10px 40px; border: none; background-color: $green-color; color: $white-color; } } } ================================================ FILE: app/styles/components/_settingsMenu.scss ================================================ @import '../base/_main'; .permissions-dialog-container { display: flex; flex-direction: row; .column { width: 50%; height: 100vh; overflow: hidden; display: flex; flex-direction: column; } .left { background: #F5F5F5; } .selected { text-decoration: underline; } .right { background-color: $green-color; } form { margin: 10px; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; align-content: flex-start; p { margin-top: 5px; margin-bottom: 5px; font-size: 9px; color: #FFFFFF; } } .form-head { opacity: 0.7; } ul { padding: 0; margin: auto; } li { list-style: none; overflow: hidden; white-space: nowrap; } } ================================================ FILE: app/styles/components/_statusMenu.scss ================================================ @import '../base/_main'; .status-menu-container { width: 100%; height: 95vh; } .status-menu-bar { background-color: $green-color; display: flex; flex-direction: row; padding: 10px; width: 100%; height: 40px; p, span { color: $white-color; } div { display: flex; flex-direction: column; } .align-start { align-items: flex-start; } .bigger { font-size: 1.1em; } .settings { height: 20px; width: 20px; background-image: url('../images/settings.png'); background-size: contain; background-repeat: no-repeat; top: 0; right: 0; margin: 10px; position: absolute; } } .status-menu-filelist { list-style: none; margin: 0; padding: 0; width: 100%; li { align-items: center; display: flex; flex-direction: row; justify-content: space-between; height: 15px; padding: 8px; } li:hover { background-color: $grey-color; } .status-icon { background-size: contain; background-repeat: no-repeat; height: 16px; width: 16px; } .uploaded { background-image: url('../images/okay.png'); } .failed { background-image: url('../images/error.png'); } .uploading { animation: blink 1s linear infinite; background-color: $green-color; border-radius: 100%; width: 8px; height: 8px; } } .status-menu-nofiles { align-content: center; align-items: center; display: flex; height: 80%; justify-content: center; width: 100%; } @keyframes blink { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } } ================================================ FILE: app/styles/main.scss ================================================ // Base @import 'base/_typography'; @import 'base/_main'; @import 'base/_animations'; // Components @import 'components/_accessForm.scss'; @import 'components/settingsMenu'; @import 'components/_statusMenu.scss'; ================================================ FILE: index.html ================================================ S3 Uploader
================================================ FILE: main.js ================================================ const menubar = require('menubar'); const fs = require('fs'); const path = require('path'); const notifier = require('node-notifier'); const { ipcMain, clipboard, Menu } = require('electron'); const configService = require('./ConfigurationService'); const S3Service = require('./S3Service'); /** * Electron window high-level wrapped constructor. */ const mb = menubar({ width: 400, height: 300, }); let s3 = null; /** * Sends data from main Electron process to renderer process. * Works only if window has been instantiated first. * @param thread * @param data */ const sendWebContentsMessage = (thread, data) => { if (mb.window !== undefined) { mb.window.webContents.send(thread, { data, }); } else { console.warn('mb.window is not defined, sending message ignored...'); } }; /** * Sends OS-level native notification invoker * @param title * @param message */ const sendNotification = (title, message) => { notifier.notify({ title, message, }); }; /** * S3.ManagedUpload high-level wrapper. * * Forwards EventEmitter events to IPC Bus to renderer process. * Sends notification if process fails or succeeds. * Automatically replaces clipboard contents with URL to file in AWS S3. * * @param uploadEventEmitter * @param file */ const handleUpload = (uploadEventEmitter, file) => { sendWebContentsMessage('UPLOAD_START', file); uploadEventEmitter.on('error', (error) => { sendNotification('Upload Error!', 'Click icon for more details...'); sendWebContentsMessage('UPLOAD_ERROR', error); }); uploadEventEmitter.on('progress', (data) => { sendWebContentsMessage('UPLOAD_PROGRESS', data); }); uploadEventEmitter.on('success', (data) => { clipboard.writeText(data.Location); sendNotification('File Uploaded!', 'Link has been copied to clipboard'); sendWebContentsMessage('UPLOAD_SUCCESS', data); }); }; /** * Function returns Promise resolved if supplied path is directory (contains files inside this * directory) or rejected if it's file. * @param file */ const checkForDirectory = (file) => new Promise((resolve, reject) => { fs.stat(file, (err, stats) => { if (err) { throw new Error(err); } else if (stats.isDirectory()) { fs.readdir(file, (error, files) => { if (err) { throw new Error(error); } return resolve(files.map((node) => path.join(file, node))); }); } else { return reject(); } }); }); /** * Reads file and passes it's binary data for upload. * @param file */ const readFileAndUpload = (file) => { fs.readFile(file, (err, data) => { if (err) throw new Error(err); handleUpload(s3.uploadFile(file.split('/').pop(), data), file); }); }; /** * Callback function for handling drop-files events. * * Takes array of files (directories) as argument and processes sequentially for upload. * * Fails if S3Service has been not initialized yet. * @param files */ const handleFiles = (files) => { if (s3 !== null) { files.forEach((file) => { checkForDirectory(file) .then((dirFiles) => handleFiles(dirFiles)) .catch(() => readFileAndUpload(file)); }); } else { throw new Error('Client has been not initialized yet!'); } }; /** * MenuBar EventEmitter listener. * * Listens for window readiness and loads config from file. * If config is present, instantiates S3Service basing on that information. */ mb.on('ready', () => { const template = [{label: 'Actions', submenu: [{role: "paste"}, {role: "quit"}]}]; const menu = Menu.buildFromTemplate(template); //Menu.setApplicationMenu(menu); configService.loadConfig() .then((config) => { if (config.bucket !== null) { s3 = new S3Service(config.accessKey, config.secretKey); } else { throw new Error('Not ready for uploading, please configure tool first.'); } }) .catch(() => { throw new Error('Error while loading configuration'); }); mb.tray.on('drop-files', (event, files) => handleFiles(files)); }); /** * IPC EventEmitter listener. * Receiver of events from renderer process. */ ipcMain.on('GET_BUCKETS', (event, arg) => { s3 = new S3Service(arg.accessKey, arg.secretKey); s3.getBuckets().then((data) => { event.sender.send('GET_BUCKETS_REPLY', { success: true, data, }); configService.updateConfig(arg); }).catch((error) => { event.sender.send('GET_BUCKETS_REPLY', { success: false, error, }); }); }); ipcMain.on('UPDATE_CONFIG', (event, arg) => { configService.updateConfig(arg); }); ipcMain.on('DROP_FILES', (event, arg) => { handleFiles(arg.files); }); ================================================ FILE: package.json ================================================ { "name": "s3-uploader", "version": "0.0.2", "description": "AWS S3 Uploader ", "main": "main.js", "scripts": { "start": "webpack -w & electron .", "clean": "rm -fr node_modules && npm install", "eslint": "eslint .", "prod": "npm prune --production", "package-osx": "npm run prod && electron-packager . --platform=darwin --arch=x64", "package-win": "npm run prod && electron packager . --platform=win --arch=x64 --asar", "package-linux": "npm run prod && electron-packager . --platform=linux --arch=x64 --asar" }, "keywords": [ "Electron", "S3", "Amazon Web Services", "React" ], "author": "Rafal Wilinski", "repository": { "type": "git", "url": "https://github.com/RafalWilinski/s3-uploader.git" }, "license": "ISC", "devDependencies": { "babel-cli": "^6.10.1", "babel-core": "^6.10.4", "babel-loader": "^6.2.4", "babel-preset-es2015": "^6.9.0", "babel-preset-react": "^6.11.1", "css-loader": "^0.23.1", "devtron": "^1.2.1", "electron-debug": "^1.0.1", "electron-packager": "^7.3.0", "eslint": "^3.0.1", "eslint-config-airbnb-base": "^4.0.0", "eslint-plugin-import": "^1.10.2", "node-sass": "^3.8.0", "sass-loader": "^4.0.0", "spectron": "^3.3.0", "style-loader": "^0.13.1", "url-loader": "^0.5.7", "file-loader":"^0.9.0", "webpack": "^1.13.1" }, "dependencies": { "aws-sdk": "^2.4.4", "electron-prebuilt": "^1.2.0", "electron-packager": "^7.3.0", "menubar": "^4.1.2", "node-notifier": "^4.6.0", "react": "^15.2.0", "react-dom": "^15.2.0", "request": "^2.72.0", "file-type": "^3.9.0" } } ================================================ FILE: webpack.config.js ================================================ const webpack = require('webpack'); module.exports = { entry: [ './app/index.jsx', ], output: { filename: 'public/bundle.js', }, target: 'electron', module: { loaders: [ { test: /\.jsx?$/, exclude: /(node_modules)/, loader: 'babel', query: { presets: ['react', 'es2015'], }, }, { test: /\.scss?$/, exclude: /(node_modules)/, loader: 'style!css!sass', }, { test: /\.(jpg|png)$/, loader: 'url?limit=25000', include: /(app)/, } ], noParse: [ /aws\-sdk/, ] }, watchOptions: { poll: 100, }, plugins: [ new webpack.ExternalsPlugin('commonjs', [ 'desktop-capturer', 'electron-prebuilt', 'electron', 'ipc', 'ipc-renderer', 'native-image', 'remote', 'web-frame', 'clipboard', 'crash-reporter', 'screen', 'shell' ]) ] };