├── .gitignore ├── assets ├── icon.png └── TrayTemplate@2x.png ├── lib ├── main.js ├── log.js ├── text.js ├── app-menu.js ├── tray.js ├── settings.js ├── clipboard-sync.js ├── app.js ├── broadcast.js └── settings-ui.js ├── azure-pipelines.yml ├── text └── en_US.json ├── azure-pipelines-template.yml ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.zip 3 | 4 | npm-debug.log 5 | /node_modules/ 6 | /out/ 7 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yue/crossclip/HEAD/assets/icon.png -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | const App = require('./app') 2 | 3 | global.app = new App() 4 | app.start() 5 | -------------------------------------------------------------------------------- /assets/TrayTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yue/crossclip/HEAD/assets/TrayTemplate@2x.png -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | function write(...args) { 2 | console.log(...args) 3 | } 4 | 5 | module.exports = {write} 6 | -------------------------------------------------------------------------------- /lib/text.js: -------------------------------------------------------------------------------- 1 | const osLocale = require('os-locale') 2 | 3 | const locale = osLocale.sync() 4 | try { 5 | module.exports = require(`../text/${locale}.json`) 6 | } catch (error) { 7 | module.exports = require('../text/en_US.json') 8 | } 9 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | - refs/heads/* 3 | - refs/pull/*/merge 4 | - refs/tags/* 5 | 6 | jobs: 7 | - job: windows_build 8 | displayName: 'Build for Windows' 9 | pool: 10 | vmImage: 'VS2017-Win2016' 11 | steps: 12 | - template: azure-pipelines-template.yml 13 | 14 | - job: macos_build 15 | displayName: 'Build for macOS' 16 | pool: 17 | vmImage: 'macOS-10.14' 18 | steps: 19 | - template: azure-pipelines-template.yml 20 | 21 | - job: linux_build 22 | displayName: 'Build for Linux' 23 | pool: 24 | vmImage: 'ubuntu-18.04' 25 | steps: 26 | - script: | 27 | sudo apt-get update 28 | sudo apt-get install -y libgtk-3-dev 29 | displayName: Install dependencies 30 | - template: azure-pipelines-template.yml 31 | -------------------------------------------------------------------------------- /lib/app-menu.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | const packageJson = require('../package.json') 4 | const text = require('./text') 5 | 6 | function setup(window) { 7 | const menu = gui.MenuBar.create([ 8 | { 9 | label: packageJson.build.productName, 10 | submenu: [ 11 | { 12 | label: text.quit, 13 | onClick: () => global.app.quit() 14 | } 15 | ] 16 | }, 17 | { 18 | label: text.edit, 19 | submenu: [ 20 | { role: 'undo' }, 21 | { role: 'redo' }, 22 | { type: 'separator' }, 23 | { role: 'cut' }, 24 | { role: 'copy' }, 25 | { role: 'paste' }, 26 | { role: 'select-all' }, 27 | ] 28 | } 29 | ]) 30 | if (process.platform == 'darwin') 31 | gui.app.setApplicationMenu(menu) 32 | else 33 | window.setMenuBar(menu) 34 | } 35 | 36 | module.exports = {setup} 37 | -------------------------------------------------------------------------------- /text/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": "Port", 3 | "portPrompt": "Please choose a port between 1024 ~ 65535, which will be used to send and receive clipboard notifications:", 4 | "portNumberError": "Port must be an integer", 5 | "channel": "Channel", 6 | "channelPrompt": "Please enter an non-empty channel, only the computers that share the same channel will have their clipboard synchronized:", 7 | "channelEmptyError": "Channel must no be empty", 8 | "key": "Key", 9 | "keyPrompt": "Please enter an non-empty key, which will be used to encrypt/decrypt the contents of clipboard:", 10 | "keyEmptyError": "Key must not be empty", 11 | "ok": "OK", 12 | "cancel": "Cancel", 13 | "status": "Status", 14 | "notStarted": "Not started", 15 | "starting": "Starting", 16 | "listening": "Listening on", 17 | "closing": "Closing", 18 | "settings": "Settings", 19 | "edit": "Edit", 20 | "quit": "Quit" 21 | } 22 | -------------------------------------------------------------------------------- /azure-pipelines-template.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - task: NodeTool@0 3 | inputs: 4 | versionSpec: 12.x 5 | 6 | - bash: | 7 | npm install 8 | npm run dist 9 | displayName: Build 10 | 11 | - bash: | 12 | BRANCH=$(Build.SourceBranch) 13 | TAG=${BRANCH:10} 14 | echo "##vso[task.setvariable variable=Name;isOutput=true]$TAG" 15 | displayName: Get Tag Name 16 | name: Tag 17 | condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/') 18 | 19 | - task: GithubRelease@0 20 | displayName: Create GitHub Release 21 | condition: startsWith(variables['Tag.Name'], 'v') 22 | inputs: 23 | gitHubConnection: GitHub Yue 24 | repositoryName: yue/crossclip 25 | action: Edit 26 | tagSource: auto 27 | tag: $(Tag.Name) 28 | title: CrossClip $(Tag.name) 29 | releaseNotesSource: input 30 | releaseNotes: (placeholder) 31 | assets: '*.zip' 32 | assetUploadMode: replace 33 | isDraft: true 34 | addChangelog: false 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crossclip", 3 | "version": "0.1.1", 4 | "main": "lib/main.js", 5 | "description": "Sync clipboard across platforms", 6 | "build": { 7 | "appId": "org.yue.crossclip", 8 | "productName": "CrossClip", 9 | "copyright": "Copyright © 2020 Cheng Zhao", 10 | "unpack": "+(*.node|*.png)" 11 | }, 12 | "scripts": { 13 | "start": "yode .", 14 | "build": "yackage build out", 15 | "dist": "yackage dist out" 16 | }, 17 | "license": "MIT", 18 | "homepage": "https://github.com/yue/crossclip", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/yue/crossclip.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/yue/crossclip/issues" 25 | }, 26 | "dependencies": { 27 | "bobolink": "3.0.0", 28 | "fast-deep-equal": "3.1.3", 29 | "fetch-yode": "1.x", 30 | "fs-extra": "9.0.1", 31 | "gui": "0.8.8", 32 | "ip": "1.1.5", 33 | "os-locale": "5.0.0" 34 | }, 35 | "devDependencies": { 36 | "yackage": "0.3.x" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/tray.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const gui = require('gui') 4 | 5 | const text = require('./text') 6 | 7 | class Tray { 8 | constructor() { 9 | const iconName = process.platform == 'darwin' ? 'TrayTemplate@2x.png' 10 | : 'icon.png' 11 | this.trayIcon = gui.Image.createFromPath(fs.realpathSync(path.join(__dirname, '..', 'assets', iconName))) 12 | this.tray = gui.Tray.createWithImage(this.trayIcon) 13 | 14 | this.statusItem = gui.MenuItem.create({label: ''}) 15 | const menu = gui.Menu.create([ 16 | { 17 | label: text.settings, 18 | onClick: () => global.app.editSettings() 19 | }, 20 | this.statusItem, 21 | { type: 'separator' }, 22 | { 23 | label: text.quit, 24 | onClick: () => global.app.quit() 25 | }, 26 | ]) 27 | this.tray.setMenu(menu) 28 | } 29 | 30 | remove() { 31 | this.tray.remove() 32 | } 33 | 34 | setStatus(status) { 35 | this.statusItem.setLabel(text.status + ': ' + status) 36 | } 37 | } 38 | 39 | module.exports = Tray 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cheng Zhao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CrossClip 2 | 3 | Sync clipboard across macOS/Linux/Windows on LAN. 4 | 5 | Written in Node.js, with native UI powered by [the Yue library](https://github.com/yue/yue). 6 | 7 | ## How to use 8 | 9 | * Download the software from [Releases page](https://github.com/yue/crossclip/releases). 10 | * You will be asked to fill some information on first run, you should at least 11 | change "channel" and "key" to unique strings. 12 | * Repeat on your other computers, and make sure the port/channel/key are same. 13 | 14 | 15 | 16 | ## Notes 17 | 18 | * Only plain text are synchronized, there is currently no plan to implement file 19 | copy/paste. 20 | * The network part is implemented by broadcasting UDP messages on LAN, so large 21 | text in clipboard could fail to be sent. There is plan to rewrite the network 22 | code with a proper P2P library to support sending large text. 23 | 24 | ## Contributions 25 | 26 | I do not plan to spend too much time maintaining this project, so if you want to 27 | add a major new feature, I would suggest forking this project instead of sending 28 | a pull request, and I would be very happy to add a link to your fork here. 29 | 30 | Bug reports and fixes would still be very much appreciated. 31 | 32 | ## Icon 33 | 34 | The [application's icon](https://www.iconfinder.com/icons/2530830) 35 | is designed by [BomSymbols](https://www.iconfinder.com/korawan_m). 36 | -------------------------------------------------------------------------------- /lib/settings.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const os = require('os') 3 | const fs = require('fs-extra') 4 | 5 | const log = require('./log') 6 | 7 | // Increase this number when there are incompatible changes to config file. 8 | const CONFIG_VERSION = 4 9 | 10 | class Settings { 11 | constructor() { 12 | this.dir = getConfigDir(require('../package.json').name) 13 | this.file = path.join(this.dir, 'config.json') 14 | fs.ensureFileSync(this.file) 15 | 16 | try { 17 | this.config = JSON.parse(fs.readFileSync(this.file)) 18 | if (this.config.version != CONFIG_VERSION) { 19 | fs.copySync(this.file, path.join(this.dir, 'config.json.old')) 20 | throw new Error('Mismatched config version:', this.config.version) 21 | } 22 | } catch (error) { 23 | this.config = {} 24 | this.set(this.getDefaultConfig()) 25 | } 26 | } 27 | 28 | set(config) { 29 | Object.assign(this.config, config) 30 | try { 31 | fs.writeJsonSync(this.file, this.config, {spaces: 2}) 32 | } catch (error) { 33 | log.write('Failed to write settings to disk:', error) 34 | } 35 | } 36 | 37 | getDefaultConfig() { 38 | return { 39 | version: CONFIG_VERSION, 40 | firstRun: true, 41 | port: 21007, 42 | channel: 'crossclip', 43 | key: 'your password', 44 | } 45 | } 46 | } 47 | 48 | function getConfigDir(name) { 49 | switch (process.platform) { 50 | case 'win32': 51 | if (process.env.APPDATA) 52 | return path.join(process.env.APPDATA, name) 53 | else 54 | return path.join(os.homedir(), 'AppData', 'Roaming', name) 55 | case 'darwin': 56 | return path.join(os.homedir(), 'Library', 'Application Support', name) 57 | case 'linux': 58 | if (process.env.XDG_CONFIG_HOME) 59 | return path.join(process.env.XDG_CONFIG_HOME, name) 60 | else 61 | return path.join(os.homedir(), '.config', name) 62 | default: 63 | throw new Error('Unknown platform') 64 | } 65 | } 66 | 67 | module.exports = Settings 68 | -------------------------------------------------------------------------------- /lib/clipboard-sync.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | const equal = require('fast-deep-equal') 3 | 4 | const log = require('./log') 5 | 6 | class ClipboardSync { 7 | constructor() { 8 | this.broadcast = null 9 | this.onMessage = this.writeToClipboard.bind(this) 10 | 11 | this.clipboard = gui.Clipboard.get() 12 | this.clipboardContent = null 13 | 14 | this.isClipboardWriting = false 15 | this.clipboard.startWatching() 16 | this.clipboard.onChange = this.onClipboardChange.bind(this) 17 | } 18 | 19 | setBroadcast(broadcast) { 20 | if (this.broadcast) 21 | this.broadcast.removeListener('message', this.onMessage) 22 | this.broadcast = broadcast 23 | this.broadcast.on('message', this.onMessage) 24 | } 25 | 26 | onClipboardChange() { 27 | // Save current result, do nothing if content is not really changed. 28 | const content = this.getClipboardContent() 29 | if (equal(content, this.clipboardContent)) 30 | return 31 | this.clipboardContent = content 32 | // Do nothing if clipboard is being written by us or is empty. 33 | if (this.isClipboardWriting || content.length == 0) 34 | return 35 | // Broadcast. 36 | if (!this.broadcast) 37 | return 38 | this.broadcast.send(JSON.stringify(content)).then(ts => { 39 | if (ts.err) 40 | log.write('Error when sending message:', ts.err) 41 | if (ts.runTime > 2000) 42 | log.write('Broadcast run time is too long:', ts.runTime) 43 | }) 44 | } 45 | 46 | writeToClipboard(text) { 47 | this.isClipboardWriting = true 48 | try { 49 | const content = JSON.parse(text) 50 | this.clipboard.setData(content) 51 | this.clipboardContent = content 52 | } catch (error) { 53 | log.write('Error when writing clipboard:', error) 54 | } finally { 55 | this.isClipboardWriting = false 56 | } 57 | } 58 | 59 | getClipboardContent() { 60 | const data = [] 61 | // if (this.clipboard.isDataAvailable('html')) 62 | // data.push(this.clipboard.getData('html')) 63 | if (this.clipboard.isDataAvailable('text')) 64 | data.push(this.clipboard.getData('text')) 65 | return data 66 | } 67 | } 68 | 69 | module.exports = ClipboardSync 70 | -------------------------------------------------------------------------------- /lib/app.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | const Broadcast = require('./broadcast') 4 | const ClipboardSync = require('./clipboard-sync') 5 | const Settings = require('./settings') 6 | const SettingsUI = require('./settings-ui') 7 | const Tray = require('./tray') 8 | 9 | const log = require('./log') 10 | const text = require('./text') 11 | const appMenu = require('./app-menu') 12 | 13 | class App { 14 | constructor() { 15 | this.tray = new Tray() 16 | this.tray.setStatus(text.notStarted) 17 | this.settings = new Settings() 18 | this.clipboardSync = new ClipboardSync() 19 | if (process.platform == 'darwin') { 20 | gui.app.setActivationPolicy('accessory') 21 | appMenu.setup() 22 | } 23 | } 24 | 25 | async start() { 26 | if (this.settings.config.firstRun) 27 | await this.configForFirstTime() 28 | this.broadcast = new Broadcast(this.settings.config) 29 | try { 30 | this.tray.setStatus(text.starting + '...') 31 | await this.broadcast.start() 32 | this.clipboardSync.setBroadcast(this.broadcast) 33 | this.tray.setStatus(`${text.listening} ${this.broadcast.ip}:${this.broadcast.port}`) 34 | } catch (error) { 35 | log.write('Failed to start broadcast service:', error) 36 | this.tray.setStatus('✗') 37 | } 38 | } 39 | 40 | async quit() { 41 | if (this.broadcast) 42 | await this.broadcast.close() 43 | this.tray.remove() 44 | gui.MessageLoop.quit() 45 | } 46 | 47 | async editSettings() { 48 | // Only one settings window allowed. 49 | if (this.settingsUI) { 50 | this.settingsUI.show() 51 | return 52 | } 53 | // Read settings. 54 | this.settingsUI = new SettingsUI(this.settings) 55 | const result = await this.settingsUI.run() 56 | this.settingsUI = null 57 | if (!result) 58 | return 59 | // Destroy current broadcast service. 60 | if (this.broadcast) { 61 | this.tray.setStatus(text.closing + '...') 62 | await this.broadcast.close() 63 | } 64 | // Apply new settings and start. 65 | this.settings.set(result) 66 | await this.start() 67 | } 68 | 69 | async configForFirstTime() { 70 | this.settingsUI = new SettingsUI(this.settings) 71 | const result = await this.settingsUI.runForFirstTime() 72 | this.settingsUI = null 73 | const config = {firstRun: false} 74 | if (result) 75 | Object.assign(config, result) 76 | this.settings.set(config) 77 | } 78 | 79 | async showSettings() { 80 | return result; 81 | } 82 | } 83 | 84 | module.exports = App 85 | -------------------------------------------------------------------------------- /lib/broadcast.js: -------------------------------------------------------------------------------- 1 | const events = require('events') 2 | const crypto = require('crypto') 3 | const dgram = require('dgram') 4 | const ip = require('ip') 5 | const Bobolink = require('bobolink') 6 | 7 | const packageJson = require('../package.json') 8 | const log = require('./log') 9 | 10 | class Broadcast extends events.EventEmitter { 11 | constructor({port, channel, key}) { 12 | super() 13 | this.testMode = false 14 | this.port = port 15 | this.channel = channel 16 | this.queue = new Bobolink({concurrency: 1}) 17 | 18 | this.key = crypto.createHmac('sha256', key).digest('hex').substr(0, 32) 19 | this.iv = 'valar morghulis!' 20 | 21 | this.ip = ip.address() 22 | this.subnetMask = '255.255.255.0' 23 | this.broadcastAddress = ip.subnet(this.ip, this.subnetMask).broadcastAddress 24 | 25 | this.server = dgram.createSocket('udp4') 26 | this.server.on('message', this.handleMessage.bind(this)) 27 | 28 | this.client = dgram.createSocket('udp4') 29 | this.client.bind(() => this.client.setBroadcast(true)) 30 | } 31 | 32 | async start() { 33 | return new Promise((resolve, reject) => { 34 | this.server.bind(this.port, (error) => { 35 | if (error) 36 | reject(error) 37 | else 38 | resolve() 39 | }) 40 | }) 41 | } 42 | 43 | async close() { 44 | return new Promise((resolve) => { 45 | this.server.close(() => { 46 | this.client.close(() => resolve()) 47 | }) 48 | }) 49 | } 50 | 51 | async send(message) { 52 | const m = { 53 | version: packageJson.version, 54 | channel: this.channel, 55 | message: this.encrypt(message), 56 | } 57 | return await this.queue.put(this._doSend.bind(this, JSON.stringify(m))) 58 | } 59 | 60 | async _doSend(message) { 61 | return new Promise((resolve, reject) => { 62 | this.client.send(message, this.port, this.broadcastAddress, (error) => { 63 | if (error) 64 | reject(error) 65 | else 66 | resolve() 67 | }) 68 | }) 69 | } 70 | 71 | handleMessage(msg, rinfo) { 72 | if (!this.testMode && rinfo.address == this.ip) 73 | return 74 | try { 75 | const json = JSON.parse(msg) 76 | if (json.version != packageJson.version) { 77 | log.write('Ignoring message from mismatched version:', json.version) 78 | return 79 | } 80 | if (json.channel != this.channel) { 81 | log.write('Ignoring message from mismatched channel:', json.channel) 82 | return 83 | } 84 | this.emit('message', this.decrypt(json.message)) 85 | } catch (error) { 86 | log.write('Error when handling message:', error) 87 | } 88 | } 89 | 90 | encrypt(text) { 91 | const cipher = crypto.createCipheriv('aes-256-cbc', this.key, this.iv) 92 | const encrypted = cipher.update(text) 93 | return Buffer.concat([encrypted, cipher.final()]).toString('hex') 94 | } 95 | 96 | decrypt(text) { 97 | const encryptedText = Buffer.from(text, 'hex') 98 | const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, this.iv) 99 | const decrypted = decipher.update(encryptedText) 100 | return Buffer.concat([decrypted, decipher.final()]).toString() 101 | } 102 | } 103 | 104 | module.exports = Broadcast 105 | -------------------------------------------------------------------------------- /lib/settings-ui.js: -------------------------------------------------------------------------------- 1 | const gui = require('gui') 2 | 3 | const packageJson = require('../package.json') 4 | const appMenu = require('./app-menu') 5 | const text = require('./text') 6 | 7 | const windowWidth = 340 8 | const promptFont = gui.Font.default().derive(-1, 'light', 'normal') 9 | const errorFont = gui.Font.default().derive(-1, 'light', 'normal') 10 | 11 | class SettingsUI { 12 | constructor(settings) { 13 | this.settings = settings 14 | 15 | this.window = gui.Window.create({}) 16 | this.window.setTitle(packageJson.build.productName) 17 | this.window.setMaximizable(false) 18 | this.window.setMinimizable(false) 19 | 20 | if (process.platform != 'darwin') 21 | appMenu.setup(this.window) 22 | 23 | const contentView = gui.Container.create() 24 | contentView.setStyle({padding: 10}) 25 | this.portEntry = this._createEntry(contentView, 'port') 26 | this.channelEntry = this._createEntry(contentView, 'channel') 27 | this.keyEntry = this._createEntry(contentView, 'key') 28 | const separator = gui.Separator.create('horizontal') 29 | separator.setStyle({flex: 1, marginBottom: 10}) 30 | contentView.addChildView(separator) 31 | const buttonRow = gui.Container.create() 32 | buttonRow.setStyle({flexDirection: 'row', justifyContent: 'flex-end'}) 33 | contentView.addChildView(buttonRow) 34 | this.errorLabel = gui.Label.create('') 35 | this.errorLabel.setAlign('start') 36 | this.errorLabel.setStyle({flex: 1}) 37 | buttonRow.addChildView(this.errorLabel) 38 | this.okButton = this._createButton(buttonRow, 'ok') 39 | this.okButton.setEnabled(false) 40 | this.cancelButton = this._createButton(buttonRow, 'cancel') 41 | this.cancelButton.onClick = () => this.window.close() 42 | 43 | const size = { 44 | width: windowWidth, 45 | height: contentView.getPreferredHeightForWidth(windowWidth) 46 | } 47 | this.window.setContentSizeConstraints(size, {width: 1000, height: size.height}) 48 | this.window.setContentSize(size) 49 | this.window.setContentView(contentView) 50 | } 51 | 52 | async run() { 53 | return new Promise((resolve, reject) => { 54 | this.portEntry.setText(String(this.settings.config.port)) 55 | this.channelEntry.setText(String(this.settings.config.channel)) 56 | this.keyEntry.setText(String(this.settings.config.key)) 57 | 58 | this.window.onClose = () => resolve(false) 59 | this.okButton.onClick = () => this._onOkClicked((result) => { 60 | this.window.onClose.disconnectAll() 61 | setTimeout(() => this.window.close()) 62 | resolve(result) 63 | }) 64 | 65 | this.show() 66 | }) 67 | } 68 | 69 | async runForFirstTime() { 70 | this.cancelButton.setVisible(false) 71 | this.okButton.setEnabled(true) 72 | await this.run() 73 | } 74 | 75 | show() { 76 | this.window.center() 77 | this.window.activate() 78 | } 79 | 80 | _onOkClicked(resolve) { 81 | const result = {} 82 | result.port = this.portEntry.getText().trim() 83 | if (/^\d+$/.test(result.port)) { 84 | result.port = Number(result.port) 85 | } else { 86 | this._reportError(text.portNumberError) 87 | return 88 | } 89 | result.channel = this.channelEntry.getText().trim() 90 | if (result.channel.length == 0) { 91 | this._reportError(text.channelEmptyError) 92 | return 93 | } 94 | result.key = this.keyEntry.getText().trim() 95 | if (result.key.length == 0) { 96 | this._reportError(text.keyEmptyError) 97 | return 98 | } 99 | resolve(result) 100 | } 101 | 102 | _reportError(message) { 103 | const text = gui.AttributedText.create(message, { 104 | font: errorFont, 105 | color: '#F00' 106 | }) 107 | this.errorLabel.setAttributedText(text) 108 | this.errorLabel.setVisible(true) 109 | this.okButton.setEnabled(false) 110 | } 111 | 112 | _createEntry(contentView, name) { 113 | const prompt = gui.Label.create(text[name + 'Prompt']) 114 | prompt.setAlign('start') 115 | prompt.setFont(promptFont) 116 | prompt.setStyle({paddingBottom: 5}) 117 | contentView.addChildView(prompt) 118 | const row = gui.Container.create() 119 | row.setStyle({paddingBottom: 10}) 120 | contentView.addChildView(row) 121 | row.setStyle({flexDirection: 'row'}) 122 | const nameLabel = gui.Label.create(text[name]) 123 | nameLabel.setAlign('start') 124 | nameLabel.setStyle({width: 80}) 125 | row.addChildView(nameLabel) 126 | const entry = gui.Entry.create() 127 | entry.setStyle({flex: 1}) 128 | entry.onTextChange = () => { 129 | this.errorLabel.setVisible(false) 130 | this.okButton.setEnabled(true) 131 | } 132 | row.addChildView(entry) 133 | return entry 134 | } 135 | 136 | _createButton(buttonRow, name) { 137 | const button = gui.Button.create(text[name]) 138 | button.setStyle({marginLeft: 10, width: 60}) 139 | buttonRow.addChildView(button) 140 | return button 141 | } 142 | } 143 | 144 | module.exports = SettingsUI 145 | --------------------------------------------------------------------------------