18 | );
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/containers/AliveServicesList.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import List from '../components/List';
3 | import { connectToService } from '../server';
4 |
5 | const mapStateToProps = ({ aliveServices, establishedConnections }) => ({
6 | emptyListText: 'As soon as a device comes alive, it will be displayed here.',
7 | list: Object.values(aliveServices).map(aliveService => ({
8 | ...aliveService,
9 | active: establishedConnections.includes(aliveService.id), // Whether there's a connection or not
10 | })),
11 | });
12 |
13 | const mapDispatchToProps = () => ({
14 | onItemClick(id) {
15 | connectToService(id);
16 | },
17 | });
18 |
19 | export default connect(mapStateToProps, mapDispatchToProps)(List);
20 |
--------------------------------------------------------------------------------
/.compilerc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "development": {
4 | "application/javascript": {
5 | "presets": [
6 | ["env", { "targets": { "electron": "1.6.0" } }],
7 | "react"
8 | ],
9 | "plugins": ["transform-async-to-generator", "transform-es2015-classes", "babel-plugin-transform-object-rest-spread", "react-hot-loader/babel"],
10 | "sourceMaps": "inline"
11 | }
12 | },
13 | "production": {
14 | "application/javascript": {
15 | "presets": [
16 | ["env", { "targets": { "electron": "1.6.0" } }],
17 | "react"
18 | ],
19 | "plugins": ["transform-async-to-generator", "transform-es2015-classes", "babel-plugin-transform-object-rest-spread"],
20 | "sourceMaps": "none"
21 | }
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { createStore } from 'redux';
5 | import { AppContainer } from 'react-hot-loader';
6 | import reducers from './reducers';
7 | import { addAliveService, addEstablishedConnection } from './actions';
8 | import server from './server';
9 | import { ALIVE_SERVICE, ESTABLISHED_CONNECTION } from './constants/server-notifications';
10 | import App from './App';
11 |
12 | let store = createStore(reducers);
13 |
14 | server.on(ALIVE_SERVICE, data => {
15 | store.dispatch(addAliveService(data));
16 | });
17 |
18 | server.on(ESTABLISHED_CONNECTION, uuid => {
19 | store.dispatch(addEstablishedConnection(uuid));
20 | });
21 |
22 | let render = () => {
23 | ReactDOM.render(
24 |
25 |
26 |
27 |
28 | ,
29 | document.getElementById('App') // eslint-disable-line
30 | );
31 | };
32 |
33 | render();
34 |
35 | if (module.hot) {
36 | module.hot.accept(render);
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018-present Tiago Tristao
4 |
5 | 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:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | 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.
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | import { app, BrowserWindow } from 'electron';
2 | import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
3 | import { enableLiveReload } from 'electron-compile';
4 |
5 | // Keep a global reference of the window object, if you don't, the window will
6 | // be closed automatically when the JavaScript object is garbage collected.
7 | let mainWindow;
8 |
9 | const isDevMode = process.execPath.match(/[\\/]electron/);
10 |
11 | if (isDevMode) enableLiveReload({ strategy: 'react-hmr' });
12 |
13 | const createWindow = async () => {
14 | // Create the browser window.
15 | mainWindow = new BrowserWindow({
16 | width: 420,
17 | height: 160,
18 | title: 'Clipmir',
19 | backgroundColor: '#222631',
20 | center: true,
21 | resizable: isDevMode,
22 | maximizable: false,
23 | fullscreen: false,
24 | fullscreenable: false,
25 | });
26 |
27 | // and load the index.html of the app.
28 | mainWindow.loadURL(`file://${__dirname}/index.html`);
29 |
30 | // Open the DevTools.
31 | if (isDevMode) {
32 | await installExtension(REACT_DEVELOPER_TOOLS);
33 | mainWindow.webContents.openDevTools();
34 | }
35 |
36 | // Emitted when the window is closed.
37 | mainWindow.on('closed', () => {
38 | // Dereference the window object, usually you would store windows
39 | // in an array if your app supports multi windows, this is the time
40 | // when you should delete the corresponding element.
41 | mainWindow = null;
42 | });
43 | };
44 |
45 | // This method will be called when Electron has finished
46 | // initialization and is ready to create browser windows.
47 | // Some APIs can only be used after this event occurs.
48 | app.on('ready', createWindow);
49 |
50 | // Quit when all windows are closed.
51 | app.on('window-all-closed', () => {
52 | // On OS X it is common for applications and their menu bar
53 | // to stay active until the user quits explicitly with Cmd + Q
54 | if (process.platform !== 'darwin') {
55 | app.quit();
56 | }
57 | });
58 |
59 | app.on('activate', () => {
60 | // On OS X it's common to re-create a window in the app when the
61 | // dock icon is clicked and there are no other windows open.
62 | if (mainWindow === null) {
63 | createWindow();
64 | }
65 | });
66 |
67 | // In this file you can include the rest of your app's specific main process
68 | // code. You can also put them in separate files and import them here.
69 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "clipmir-desktop",
3 | "productName": "clipmir-desktop",
4 | "version": "0.0.1",
5 | "description": "A cross platform desktop app for mirroring the clipboard between all synced devices in the same network",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/tiagovtristao/clipmir-desktop.git"
10 | },
11 | "bugs": "https://github.com/tiagovtristao/clipmir-desktop/issues",
12 | "keywords": [
13 | "clipboard",
14 | "sync",
15 | "universal"
16 | ],
17 | "author": "Tiago Tristao (https://github.com/tiagovtristao)",
18 | "main": "./app/index.js",
19 | "scripts": {
20 | "start": "electron-forge start",
21 | "package": "electron-forge package",
22 | "make": "electron-forge make",
23 | "publish": "electron-forge publish",
24 | "lint": "eslint --cache --color --ext .jsx,.js src"
25 | },
26 | "config": {
27 | "forge": {
28 | "make_targets": {
29 | "win32": [
30 | "squirrel"
31 | ],
32 | "darwin": [
33 | "zip"
34 | ],
35 | "linux": [
36 | "deb",
37 | "rpm"
38 | ]
39 | },
40 | "electronPackagerConfig": {
41 | "packageManager": "yarn"
42 | },
43 | "electronWinstallerConfig": {
44 | "name": "clipmir_desktop"
45 | },
46 | "electronInstallerDebian": {},
47 | "electronInstallerRedhat": {},
48 | "github_repository": {
49 | "owner": "",
50 | "name": ""
51 | },
52 | "windowsStoreConfig": {
53 | "packageName": "",
54 | "name": "clipmir"
55 | }
56 | }
57 | },
58 | "dependencies": {
59 | "electron-compile": "^6.4.3",
60 | "electron-devtools-installer": "^2.1.0",
61 | "electron-squirrel-startup": "^1.0.0",
62 | "get-port-sync": "^1.0.0",
63 | "jayson": "^2.0.6",
64 | "node-ssdp": "^3.3.0",
65 | "react": "^16",
66 | "react-dom": "^16",
67 | "react-hot-loader": "^3.0.0-beta.6",
68 | "react-redux": "^5.0.7",
69 | "redux": "^4.0.0",
70 | "uuid": "^3.3.2"
71 | },
72 | "devDependencies": {
73 | "babel-plugin-transform-async-to-generator": "^6.24.1",
74 | "babel-plugin-transform-es2015-classes": "^6.24.1",
75 | "babel-plugin-transform-object-rest-spread": "^6.26.0",
76 | "babel-preset-env": "^1.7.0",
77 | "babel-preset-react": "^6.24.1",
78 | "electron-forge": "^5.2.2",
79 | "electron-prebuilt-compile": "2.0.4",
80 | "eslint": "^3",
81 | "eslint-config-airbnb": "^15",
82 | "eslint-plugin-import": "^2",
83 | "eslint-plugin-jsx-a11y": "^5",
84 | "eslint-plugin-react": "^7"
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Clipmir (Currently only for testing!)
2 | =====================================
3 |
4 | A cross platform Electron app for mirroring the clipboard between all synced devices in the same network.
5 |
6 | Demo
7 | ----
8 |
9 | 
10 |
11 | Download
12 | --------
13 |
14 | * Windows (x64): [Squirrel](https://bintray.com/tiagovtristao/windows/download_file?file_path=clipmir-desktop-setup.zip), [MSI](https://bintray.com/tiagovtristao/windows/download_file?file_path=clipmir-desktop-msi.zip)
15 | * MacOS (x64): [ZIP](https://bintray.com/tiagovtristao/macos/download_file?file_path=clipmir-desktop-darwin-x64-0.0.1.zip)
16 | * Linux (x64): [DEB](https://bintray.com/tiagovtristao/linux/download_file?file_path=clipmir-desktop_0.0.1_amd64.deb), [RPM](https://bintray.com/tiagovtristao/linux/download_file?file_path=clipmir-desktop-0.0.1.x86_64.rpm)
17 |
18 | Development
19 | -----------
20 |
21 | ```bash
22 | git clone https://github.com/tiagovtristao/clipmir-desktop.git
23 | cd clipmir-desktop
24 | yarn install (or npm install)
25 | yarn start (or npm run start)
26 | ```
27 |
28 | Build
29 | -----
30 |
31 | ```bash
32 | git clone https://github.com/tiagovtristao/clipmir-desktop.git
33 | cd clipmir-desktop
34 | yarn install (or npm install)
35 | yarn make (or npm run make)
36 | ```
37 | > **NOTE**
38 | >
39 | > `yarn make` will produce a build based on your arch and OS. See https://electronforge.io/cli/make if you want to build for a different arch and/or OS.
40 |
41 | Motivation
42 | ----------
43 |
44 | Every now and then, I feel the need of copying some text into the clipboard and have it readily available for pasting in another computer in the same network. It's not ideal for me to paste what I have just copied into another program (i.e. emails, Slack, etc.) in the very same computer so that it can get sent "across".
45 |
46 | Truth is, although I used to need this functionality more in the past, I rarely need it nowadays. So, would I go about installing an app in each device where I need this, when I might not use it everyday? Probably not, as any of the solutions mentioned above would do just fine.
47 |
48 | So why did I go about building a first working version (currenly only for testing!), when there might already be similar solutions out there as well? Well, I did it for fun, knowing that I'd learn new things along the way too. Also, there might be someone out there who this project could pique his/her interest.
49 |
50 | How it works
51 | ------------
52 |
53 | * When the app is launched on the first device, it will notify the network - using SSDP (Simple Service Discovery Protocol) - that it is providing this clipboard mirroring service;
54 | * The exactly same thing will happen, when the app is launched on the second device;
55 | * At this point, the app in each device will list the other device;
56 | * In order to have the devices' clipboards synced, both devices have to click in each other's name in the list to establish a bi-directional flow - a green circle will appear indicating that both devices agreed to talk to each other;
57 | * If a device tries to connect with another one, but the target device doesn't connect back (by clicking on the device name in the list), then no connection is established and nothing happens;
58 | * Several devices can be connected at the same time, allowing one device to broadcast its new clipboard value to everyone connected to it.
59 |
60 | Limitations
61 | -----------
62 |
63 | * Only text can be synced, at the moment.
64 |
65 | Current State
66 | -------------
67 |
68 | This is the first minimal working version, and should be used for testing only! The features missing to make it to a secure and stable version are:
69 |
70 | * The current advertised name by a device is a UUID, which makes it difficult to know who that user is if there are more than 2 advertisers in the network. There should be an option to allow the user to set its discovery name;
71 | * There isn't a visual indicator in the UI when someone tries to connect to you and vice versa;
72 | * There's no disconnect option from a device, unless you restart the app;
73 | * The state of a device that you are connected to is not reflected on the UI when it goes offline;
74 | * Move server initialisation from the rendered process into the main process (Electron);
75 | * The JSON RPC server should be serving HTTPS instead HTTP.
76 |
77 | Future
78 | ------
79 |
80 | The basic functionality is up and running which covers the initial goal of this project. There are no plans to tackle the issues mentioned in "Current State", unless there are people interested. By the way, you are more than welcome to make pull requests :-)
81 |
82 | Mobile support was initially considered too, so that users could have their computer and phone's clipboards also synced. And although this project targets desktop devices only, most of the code could probably be ported to React Native to achieve it.
83 |
84 | Electron could be switched by native APIs since it's overkill for such a small program.
85 |
86 | License
87 | -------
88 |
89 | MIT
90 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'events';
2 | import uuidv4 from 'uuid/v4';
3 | import jayson from 'jayson';
4 | import getPortSync from 'get-port-sync';
5 | import { clipboard } from 'electron';
6 | import ssdp, { usnRegex, serviceType } from './ssdp';
7 | import objectHas from './utils/objectHas';
8 | import { ALIVE_SERVICE, ESTABLISHED_CONNECTION } from './constants/server-notifications';
9 |
10 | // Used to broacast specific server events to the outside
11 | const emitter = new EventEmitter();
12 |
13 | const processUUID = uuidv4();
14 | const port = getPortSync();
15 |
16 | let aliveServices = {};
17 | let issuedConnections = {}; // Client context: Logs connection attempts to available services
18 | let receivedConnections = {}; // Server context: Logs client connection attempts
19 | let establishedConnections = {}; // Server context: Both end machines attemped to connect to each other
20 | let previousClipboardValue = null;
21 |
22 | // Set up JSON RPC stuff
23 | function handleGetNameRequest() {
24 | console.log(Date.now(), `${processUUID} responding to 'getName' request`);
25 |
26 | return {
27 | name: processUUID, // TODO: The user should be able to set its own discovery name
28 | };
29 | }
30 |
31 | function handleConnectRequest({ uuid }) {
32 | console.log(Date.now(), `${processUUID} handling connection request from ${uuid}`);
33 |
34 | if (issuedConnections[uuid]) {
35 | delete issuedConnections[uuid];
36 | establishedConnections[uuid] = {};
37 |
38 | emitter.emit(ESTABLISHED_CONNECTION, uuid);
39 | }
40 | else {
41 | receivedConnections[uuid] = {};
42 | }
43 |
44 | return true;
45 | }
46 |
47 | function handleSetClipboardValueRequest({ uuid, value }) {
48 | console.log(Date.now(), `${processUUID} has new value '${value}' from ${uuid}`);
49 |
50 | clipboard.writeText(value);
51 | previousClipboardValue = value; // This avoids the just-received value from being sent out
52 |
53 | return true;
54 | }
55 |
56 | let jsonRpcServer = jayson.server({
57 | getName(args, callback) {
58 | callback(null, handleGetNameRequest());
59 | },
60 | connect(args, callback) {
61 | if (!objectHas(args, ['uuid'])) {
62 | callback(() => ({
63 | error: 'Invalid request',
64 | }));
65 | }
66 | else {
67 | callback(null, handleConnectRequest(args));
68 | }
69 | },
70 | setClipboardValue(args, callback) {
71 | if (!objectHas(args, ['uuid', 'value'])) {
72 | callback(() => ({
73 | error: 'Invalid request',
74 | }));
75 | }
76 | // Only connected users can make this request!
77 | else if (!establishedConnections[args.uuid]) {
78 | callback(() => ({
79 | error: 'Forbidden request',
80 | }));
81 | }
82 | else {
83 | callback(null, handleSetClipboardValueRequest(args));
84 | }
85 | },
86 | });
87 |
88 | jsonRpcServer.http().listen(port); // TODO: Move to HTTPS
89 |
90 | // Set up SSDP
91 | let ssdpInstance = ssdp({
92 | uuid: processUUID,
93 | port,
94 | });
95 |
96 | // Triggered by all advertisers including this process
97 | ssdpInstance.server.on('advertise-alive', (headers, body) => {
98 | if (headers.NT === serviceType // Look for the same service
99 | && headers.USN !== ssdpInstance.usn // But ignore this process
100 | ) {
101 | let results = usnRegex.exec(headers.USN);
102 |
103 | let uuid = results[1];
104 |
105 | // The service will try to advertise itself through all its available interfaces.
106 | // Only the first one is stored which is enough to establish a connection.
107 | if (aliveServices[uuid]) {
108 | return;
109 | }
110 |
111 | console.log(Date.now(), 'ADVERTISE-ALIVE', headers, body);
112 |
113 | aliveServices[uuid] = {
114 | location: headers.LOCATION,
115 | client: jayson.client.http(headers.LOCATION), // TODO: Move to HTTPS
116 | };
117 |
118 | aliveServices[uuid].client.request('getName', null, (err, { result: { name } }) => {
119 | if (err) {
120 | console.error(Date.now(), `Response error to 'getName' request at ${aliveServices[uuid].LOCATION} (${uuid})}`, err);
121 |
122 | return;
123 | }
124 |
125 | aliveServices[uuid].name = name;
126 |
127 | console.log(Date.now(), `New alive service registered at ${uuid}`, aliveServices[uuid]);
128 |
129 | emitter.emit(ALIVE_SERVICE, {
130 | uuid,
131 | name,
132 | });
133 | });
134 | }
135 | });
136 |
137 | ssdpInstance.server.on('advertise-bye', (...args) => {
138 | console.log(Date.now(), 'ADVERTISE-BYE', args);
139 |
140 | // TODO: Clear up after a device/service advertises to go away
141 | });
142 |
143 | // Poll the clipboard indefinitely for new values and multicast them
144 | setInterval(() => {
145 | let currentValue = clipboard.readText();
146 |
147 | if (currentValue !== previousClipboardValue && previousClipboardValue !== null) {
148 | Object.keys(establishedConnections).forEach(uuid => {
149 | console.log(Date.now(), `${processUUID} sends ${uuid} its new clipboard value '${currentValue}'`);
150 |
151 | aliveServices[uuid].client.request('setClipboardValue', { uuid: processUUID, value: currentValue }, err => {
152 | if (err) {
153 | console.error(Date.now(), `Response error to 'setClipboardValue' request at ${aliveServices[uuid].LOCATION} (${uuid})}`, err);
154 | }
155 | });
156 | });
157 | }
158 |
159 | previousClipboardValue = currentValue;
160 | }, 1000);
161 |
162 | export const connectToService = uuid => {
163 | console.log(Date.now(), `${processUUID} requests connection to ${uuid}`);
164 |
165 | aliveServices[uuid].client.request('connect', { uuid: processUUID }, err => {
166 | if (err) {
167 | console.error(Date.now(), `Response error to 'connect' request at ${aliveServices[uuid].LOCATION} (${uuid})}`, err);
168 |
169 | return;
170 | }
171 |
172 | if (receivedConnections[uuid]) {
173 | delete receivedConnections[uuid];
174 | establishedConnections[uuid] = {};
175 |
176 | console.log(Date.now(), `Connection established between ${processUUID} and ${uuid}`);
177 |
178 | emitter.emit(ESTABLISHED_CONNECTION, uuid);
179 | }
180 | else {
181 | issuedConnections[uuid] = {};
182 |
183 | console.log(Date.now(), `First step of connection starting from ${processUUID} to ${uuid} done`);
184 | }
185 | });
186 | };
187 |
188 | export default emitter;
189 |
--------------------------------------------------------------------------------