├── images ├── 16x16.png └── icon.icns ├── .gitignore ├── auto_updater.json ├── .vscode └── settings.json ├── .eslintrc.js ├── README.md ├── index.html ├── package.json ├── updater.js ├── config.js ├── settings.js └── main.js /images/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaabhilash97/cliptext-clipboard-manager/HEAD/images/16x16.png -------------------------------------------------------------------------------- /images/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaabhilash97/cliptext-clipboard-manager/HEAD/images/icon.icns -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | signer.sh 2 | **/.gitignore 3 | **/node_modules 4 | **/.DS_Store 5 | dist/github 6 | dist/mac 7 | dist/*.dmg 8 | dist/ 9 | -------------------------------------------------------------------------------- /auto_updater.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.1", 3 | "releaseDate": "2019-11-27T11:29:00.656Z", 4 | "url": "https://github.com/aaabhilash97/cliptext/releases/download/v2.0.1/cliptext-2.0.1-mac.zip" 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "USERPROFILE", 4 | "autoload", 5 | "clipboarddb", 6 | "clipboarddbpref", 7 | "submenu", 8 | "upsert" 9 | ] 10 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'commonjs': true, 5 | 'es6': true, 6 | }, 7 | 'extends': [ 8 | 'google', 9 | ], 10 | 'globals': { 11 | 'Atomics': 'readonly', 12 | 'SharedArrayBuffer': 'readonly', 13 | }, 14 | 'parserOptions': { 15 | 'ecmaVersion': 2018, 16 | }, 17 | 'rules': { 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### ClipText 2 | ClipText is a simple clipboard manager for macOS built with electron. 3 | ##### Features 4 | * Save history of your last copied texts 5 | * Global hotkey for popingup tray context menu with clipboard history. 6 | 7 | Global shortcut for launching Clipboard history is ```Cmd+Alt+h``` 8 | 9 | You can find the latest build from [releases](https://github.com/aaabhilash97/cliptext/releases) section 10 | 11 | ### Screenshots: 12 | 13 | 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 | textsms 17 | 18 | 19 |
20 |
21 |
22 |
23 | 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cliptext", 3 | "version": "2.0.1", 4 | "description": "clipboard manager", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "electron-builder", 8 | "start": "electron ." 9 | }, 10 | "build": { 11 | "appId": "com.xxxxxxx.cliptext", 12 | "files": [ 13 | "*.js", 14 | "*.html", 15 | "package.json", 16 | "node_modules", 17 | "images" 18 | ], 19 | "mac": { 20 | "icon": "./images/icon.icns" 21 | }, 22 | "asar": true 23 | }, 24 | "author": "Abhilash Km (aaabhilash97@gmail.com)", 25 | "license": "ISC", 26 | "devDependencies": { 27 | "electron": "^15.5.5", 28 | "electron-builder": "^22.1.0", 29 | "electron-rebuild": "^1.5.11", 30 | "eslint": "^6.6.0", 31 | "eslint-config-google": "^0.14.0" 32 | }, 33 | "dependencies": { 34 | "auto-launch": "^5.0.1", 35 | "electron-log": "^2.2.6", 36 | "got": "^11.8.5", 37 | "md5": "^2.2.1", 38 | "nedb": "^1.8.0", 39 | "semver": "^5.3.0" 40 | } 41 | } -------------------------------------------------------------------------------- /updater.js: -------------------------------------------------------------------------------- 1 | const {logger, packageInfos} = require('./config'); 2 | 3 | const {autoUpdater} = require('electron'); 4 | const got = require('got'); 5 | const semver = require('semver'); 6 | 7 | 8 | const appVersion = packageInfos.version; 9 | 10 | const options = { 11 | repo: 'https://raw.githubusercontent.com/aaabhilash97/cliptext/master/auto_updater.json', 12 | }; 13 | 14 | /** 15 | * Start update checker 16 | */ 17 | function checkForUpdates() { 18 | setInterval(async ()=>{ 19 | try { 20 | let data = await got(options.repo); 21 | data = JSON.parse(data.body); 22 | const regex = /-(\d+\.\d+\.\d+)-/; 23 | const version = data.url.match(regex); 24 | if (semver.gt(version[1], appVersion)) { 25 | autoUpdater.setFeedURL(options.repo); 26 | autoUpdater.on('checking-for-update', ()=>{ 27 | logger.info('checking for updates'); 28 | }); 29 | autoUpdater.on('update-available', ()=>{ 30 | logger.info('update-available'); 31 | }); 32 | autoUpdater.on('update-not-available', ()=>{ 33 | logger.info('update-not-available'); 34 | }); 35 | autoUpdater.on('update-downloaded', ()=>{ 36 | autoUpdater.quitAndInstall(); 37 | }); 38 | autoUpdater.checkForUpdates(); 39 | } 40 | } catch (error) { 41 | logger.error('Error in checking update', error); 42 | } 43 | }, 1000000); 44 | } 45 | 46 | module.exports = { 47 | checkForUpdates: checkForUpdates, 48 | }; 49 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const packageInfos = require('./package.json'); 2 | 3 | const logger = require('electron-log'); 4 | const NeDB = require('nedb'); 5 | const path = require('path'); 6 | const fs = require('fs'); 7 | 8 | logger.transports.file.level = 'error'; 9 | logger.transports.console.level = 'debug'; 10 | 11 | 12 | const DBDir = path.join(getUserHome(), `.${packageInfos.name}`); 13 | 14 | try { 15 | fs.accessSync(DBDir); 16 | } catch (err) { 17 | fs.mkdirSync(DBDir); 18 | } 19 | 20 | /** 21 | * Get Home folder for current user 22 | * @return {string} User home folder path 23 | */ 24 | function getUserHome() { 25 | return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']; 26 | } 27 | 28 | const clipboardDB = new NeDB( 29 | {filename: path.join(DBDir, '/.clipboarddbv2'), autoload: true}); 30 | const settingsDB = new NeDB( 31 | {filename: path.join(DBDir, '/.clipboarddbprefv2'), autoload: true}); 32 | 33 | settingsDB.ensureIndex({fieldName: 'key', unique: true}, function(err) { 34 | if (err) console.error(err); 35 | }); 36 | 37 | clipboardDB.ensureIndex({fieldName: 'updated_at'}, function(err) { 38 | if (err) console.error(err); 39 | }); 40 | clipboardDB.ensureIndex({fieldName: 'hash'}, function(err) { 41 | if (err) console.error(err); 42 | }); 43 | 44 | module.exports = { 45 | logger: logger, 46 | packageInfos: packageInfos, 47 | DBDir: DBDir, 48 | clipboardDB: clipboardDB, 49 | settingsDB: settingsDB, 50 | appName: packageInfos.name, 51 | isDevelopment: process.env.ENV === 'development', 52 | }; 53 | 54 | -------------------------------------------------------------------------------- /settings.js: -------------------------------------------------------------------------------- 1 | const {logger, appName, isDevelopment, settingsDB} = require('./config'); 2 | 3 | const AutoLaunch = require('auto-launch'); 4 | 5 | const autoLaunch = new AutoLaunch({ 6 | name: appName, 7 | mac: { 8 | useLaunchAgent: true, 9 | }, 10 | }); 11 | 12 | 13 | let initSync = false; 14 | const settings = { 15 | clipboardLimit: 30, 16 | autoStart: true, 17 | }; 18 | 19 | /** 20 | * @return {Promise} 21 | */ 22 | function sync() { 23 | return new Promise((resolve, reject)=>{ 24 | settingsDB.find({}).exec(async (error, results) =>{ 25 | try { 26 | if (error) { 27 | logger.error('Error in fetching settings from DB:', error); 28 | return reject(error); 29 | } 30 | for (const result of results) { 31 | settings[result.key] = result.value; 32 | } 33 | initSync = true; 34 | return resolve(settings); 35 | } catch (error) { 36 | logger.debug('Error in reading or apply settings: ', error); 37 | return reject(error); 38 | } 39 | }); 40 | }); 41 | } 42 | 43 | /** 44 | * Save settings to database 45 | * @param {String} key 46 | * @param {Any} value 47 | * @return {Promise} 48 | */ 49 | function saveToDB(key, value) { 50 | return new Promise((resolve, reject)=>{ 51 | settingsDB.update( 52 | {key: key}, 53 | {key, key, value: value}, 54 | {upsert: true}, 55 | (err)=> { 56 | if (err) { 57 | console.error(err); 58 | logger.error('Error in in update settings: ', err); 59 | return reject(err); 60 | } 61 | return resolve({ 62 | [key]: value, 63 | }); 64 | }); 65 | }); 66 | } 67 | 68 | 69 | /** 70 | * Get settings from store 71 | * @param {string} key setting key 72 | * @return {Any} setting value 73 | */ 74 | async function get(key) { 75 | if (!initSync) { 76 | await sync(); 77 | if (!isDevelopment && settings.autoStart) { 78 | const enabled = await autoLaunch.isEnabled(); 79 | logger.debug('autostart status: ', enabled); 80 | if (!enabled) await autoLaunch.enable(); 81 | } else { 82 | await autoLaunch.disable(); 83 | } 84 | } 85 | return settings[key]; 86 | } 87 | 88 | /** 89 | * 90 | * @param {String} key 91 | * @param {Any} value 92 | */ 93 | async function set(key, value) { 94 | settings[key] = value; 95 | await saveToDB(key, value); 96 | } 97 | 98 | module.exports = { 99 | get: get, 100 | set: set, 101 | }; 102 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { logger, packageInfos, clipboardDB, appName } = require("./config"); 2 | const settings = require("./settings"); 3 | const updater = require("./updater.js"); 4 | 5 | const { app, Menu, Tray, clipboard, globalShortcut } = require("electron"); 6 | const path = require("path"); 7 | const md5 = require("md5"); 8 | 9 | /* Tray elements */ 10 | let tray = null; 11 | 12 | const emptyFillerMenu = { label: "clipboard is empty", enabled: false }; 13 | const titleMenu1 = { 14 | label: `${appName} v${packageInfos.version} `, 15 | enabled: false 16 | }; 17 | const titleMenu2 = { 18 | label: `_____________________Clipboard History________________________`, 19 | enabled: false 20 | }; 21 | const menuSeparator = { 22 | label: "______________________________________________________________", 23 | enabled: false 24 | }; 25 | 26 | const clearAllMenu = { 27 | label: "Clear All", 28 | click: clearAllHistory, 29 | accelerator: "Command+Alt+c" 30 | }; 31 | 32 | const quitMenu = { label: "Quit", click: app.quit, accelerator: "Command+Q" }; 33 | 34 | const settingsMenu = { 35 | label: "Settings", 36 | submenu: [] 37 | }; 38 | const settingsAutoStartEntry = { 39 | label: "Launch on System startup", 40 | click: setAutostart, 41 | type: "radio" 42 | }; 43 | settingsMenu.submenu.push(settingsAutoStartEntry); 44 | 45 | const settingsLimitEntry = { 46 | label: "Clipboard Limit", 47 | submenu: [] 48 | }; 49 | for (const limit of [30, 50, 100, 200, 400]) { 50 | settingsLimitEntry.submenu.push({ 51 | label: String(limit), 52 | value: limit, 53 | click: setClipboardLimit, 54 | type: "radio" 55 | }); 56 | } 57 | settingsMenu.submenu.push(settingsLimitEntry); 58 | 59 | /* Tray elements end */ 60 | 61 | /** 62 | * Clear history from database 63 | */ 64 | function clearAllHistory() { 65 | clipboardDB.remove({}, { multi: true }, function(err, numRemoved) { 66 | if (err) { 67 | logger.error("Error in clearing history: ", err); 68 | } else { 69 | updateTray(); 70 | } 71 | }); 72 | } 73 | 74 | /** 75 | * Set clipboard limit value 76 | * @param {Number} value clipboard limit size 77 | */ 78 | async function setClipboardLimit(value) { 79 | try { 80 | await settings.set("clipboardLimit", value.value); 81 | await updateTray(); 82 | } catch (error) { 83 | logger.error("setClipboardLimit error: ", error); 84 | } 85 | } 86 | 87 | /** 88 | * Set auto start 89 | */ 90 | async function setAutostart() { 91 | try { 92 | const value = !settingsAutoStartEntry.checked; 93 | await settings.set("autoStart", value); 94 | await updateTray(); 95 | } catch (error) { 96 | logger.error("setAutostart error: ", error); 97 | } 98 | } 99 | 100 | /** 101 | * get clipboard limit value from db 102 | * @return {Number} 103 | */ 104 | async function getClipboardLimit() { 105 | const clipboardLimit = await settings.get("clipboardLimit"); 106 | for (const limitEntry of settingsLimitEntry.submenu) { 107 | if (limitEntry.value == clipboardLimit) { 108 | limitEntry.checked = true; 109 | } else { 110 | limitEntry.checked = false; 111 | } 112 | } 113 | return clipboardLimit; 114 | } 115 | 116 | /** 117 | * get autostart from db 118 | */ 119 | async function getAutostart() { 120 | const autoStart = await settings.get("autoStart"); 121 | settingsAutoStartEntry.checked = autoStart ? true : false; 122 | return autoStart; 123 | } 124 | 125 | /** 126 | * Read clipboard history from database 127 | * @return {Promise} 128 | */ 129 | function getClipboardItems() { 130 | return new Promise(async (resolve, reject) => { 131 | const clipboardLimit = await getClipboardLimit(); 132 | clipboardDB 133 | .find({}) 134 | .sort({ updated_at: -1 }) 135 | .limit(clipboardLimit) 136 | .exec(function(err, results) { 137 | if (err) { 138 | logger.error("DB clipboard find error: ", err); 139 | return reject(err); 140 | } 141 | return resolve(results); 142 | }); 143 | }); 144 | } 145 | 146 | /** 147 | * 148 | * @param {Any} r 149 | */ 150 | function historyClick(r) { 151 | if (r.use_label) { 152 | clipboard.writeText(r.label); 153 | } else { 154 | clipboardDB.findOne({ _id: r._id }, function(err, result) { 155 | if (err) { 156 | return logger.error("[historyClick]Error in reading DB", err); 157 | } 158 | clipboard.writeText(result.text); 159 | }); 160 | } 161 | } 162 | 163 | /** 164 | * 165 | * @param {Any} params 166 | */ 167 | async function createTray() { 168 | try { 169 | if (!tray || tray.isDestroyed()) { 170 | tray = new Tray(path.join(__dirname, "images/16x16.png")); 171 | } 172 | tray.setToolTip(packageInfos.description); 173 | tray.setTitle(appName); 174 | tray.on("right-click", () => { 175 | tray.popUpContextMenu(); 176 | }); 177 | } catch (exception) { 178 | logger.error("Exception in create tray: ", exception); 179 | } 180 | } 181 | 182 | /** 183 | * 184 | * @param {Any} params 185 | */ 186 | async function updateTray(params) { 187 | try { 188 | if (!params) params = {}; 189 | 190 | await getAutostart(); 191 | await getClipboardLimit(); 192 | 193 | const results = await getClipboardItems(); 194 | 195 | const trayItems = [titleMenu1, titleMenu2]; 196 | 197 | if (results.length === 0) { 198 | trayItems.push(emptyFillerMenu); 199 | } 200 | let i = 1; 201 | for (const item of results) { 202 | if (item.text && item._id) { 203 | const trayEntry = { 204 | _id: item._id, 205 | label: item.text.slice(0, 50), 206 | click: historyClick, 207 | accelerator: `Command+${i}` 208 | }; 209 | if (item.text.length <= 50) { 210 | trayEntry.use_label = true; 211 | } 212 | trayItems.push(trayEntry); 213 | i++; 214 | } 215 | } 216 | trayItems.push(menuSeparator, clearAllMenu, settingsMenu, quitMenu); 217 | 218 | if (!tray || tray.isDestroyed()) { 219 | createTray(); 220 | } 221 | const contextMenu = Menu.buildFromTemplate(trayItems); 222 | tray.setContextMenu(contextMenu); 223 | 224 | setTimeout(() => { 225 | if (params.popup) tray.popUpContextMenu(); 226 | }, 100); 227 | } catch (exception) { 228 | logger.error("Exception in update tray: ", exception); 229 | } 230 | } 231 | 232 | /** 233 | * Watch clipboard 234 | */ 235 | function clipboardWatch() { 236 | let currentValue = clipboard.readText(); 237 | setInterval(async () => { 238 | try { 239 | const newValue = clipboard.readText(); 240 | if (currentValue !== newValue) { 241 | currentValue = newValue; 242 | await saveClipboard(currentValue); 243 | } 244 | } catch (error) { 245 | logger.error("error in clipboard watch or saveToDb: ", err); 246 | } 247 | }, 200); 248 | } 249 | 250 | /** 251 | * Save settings to database 252 | * @param {String} text 253 | * @return {Promise} 254 | */ 255 | function saveClipboard(text) { 256 | return new Promise((resolve, reject) => { 257 | if (!text) { 258 | return resolve(); 259 | } 260 | const doc = { 261 | hash: md5(text), 262 | text: text, 263 | updated_at: new Date() 264 | }; 265 | clipboardDB.update( 266 | { hash: doc.hash }, 267 | doc, 268 | { upsert: true }, 269 | (err, numberOfUpdated, upsert) => { 270 | if (err) { 271 | logger.error("Error in in update settings: ", err); 272 | return reject(err); 273 | } 274 | if (upsert || numberOfUpdated) { 275 | updateTray(); 276 | } 277 | return resolve({}); 278 | } 279 | ); 280 | }); 281 | } 282 | 283 | if (process.platform == "darwin" && process.env.ENV != "development") { 284 | app.dock.hide(); 285 | } 286 | // Quit when all windows are closed. 287 | app.on("window-all-closed", () => { 288 | // On macOS it is common for applications and their menu bar 289 | // to stay active until the user quits explicitly with Cmd + Q 290 | if (process.platform !== "darwin") { 291 | app.quit(); 292 | } 293 | }); 294 | 295 | app.on("ready", () => { 296 | globalShortcut.register("CommandOrControl+Alt+h", () => { 297 | updateTray({ 298 | disabled: true, 299 | popup: true 300 | }); 301 | }); 302 | 303 | clipboardWatch(); 304 | createTray(); 305 | updateTray(); 306 | updater.checkForUpdates(); 307 | if (process.platform == "darwin" && process.env.ENV != "development") { 308 | app.dock.hide(); 309 | } 310 | }); 311 | --------------------------------------------------------------------------------