├── .babelrc ├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app └── app.html ├── assets ├── android_messages_desktop_icon.png └── screenshots │ ├── 1.png │ ├── mac.png │ ├── mac_notification_badge.png │ ├── windows.png │ └── windows_tray_icon.png ├── build ├── start.js ├── webpack.app.config.js ├── webpack.base.config.js ├── webpack.e2e.config.js └── webpack.unit.config.js ├── config ├── env_development.json ├── env_production.json ├── env_test.json └── packaging │ ├── macosEntitlements.plist │ └── notarize.js ├── e2e └── utils.js ├── package-lock.json ├── package.json ├── resources ├── dictionaries │ └── .gitignore ├── icon.icns ├── icon.ico ├── icons │ ├── 1024x1024.png │ ├── 128x128.png │ ├── 16x16.png │ ├── 24x24.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ └── 64x64.png └── tray │ ├── icon.png │ ├── icon@2x.png │ ├── icon_macTemplate.png │ ├── icon_macTemplate@2x.png │ └── tray_with_badge.ico └── src ├── app.js ├── background.js ├── constants └── index.js ├── helpers ├── dictionary_manager.js ├── tray │ └── tray_manager.js ├── utilities.js ├── webview │ ├── bridge.js │ ├── context_menu.js │ └── input_manager.js └── window.js ├── menu ├── app_menu_template.js ├── base_menu_template.js ├── dev_menu_template.js ├── edit_menu_template.js ├── file_menu_template.js ├── help_menu_template.js ├── items │ ├── about.js │ ├── check_for_updates.js │ └── separator.js ├── settings_menu_template.js ├── tray_menu_template.js ├── view_menu_template.js └── window_menu_template.js └── stylesheets └── main.css /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { 7 | "browsers": "last 2 Chrome versions", 8 | "node": "current" 9 | } 10 | } 11 | ] 12 | ], 13 | "plugins": [["transform-object-rest-spread", { "useBuiltIns": true }]] 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | Thumbs.db 4 | *.log 5 | 6 | /dist 7 | /temp 8 | 9 | # ignore everything in 'app' folder what had been generated from 'src' folder 10 | /app/app.js 11 | /app/background.js 12 | /app/bridge.js 13 | /app/**/*.map 14 | 15 | # Dictionary files are downloaded by the user's machine 16 | /resources/dictionaries/**/* 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.1.0] - 2019-11-26 4 | ### Added 5 | - Setting to follow (sync) system dark mode setting, changing from dark to light and vice versa as the operating system does -- This overrides the Google-provided setting within the 3-dot menu 6 | - Setting and keyboard shortcut (Command or Control +/-) to zoom the application in or out a la a web browser allows a page 7 | 8 | ### Changed 9 | - Under the hood: Notarize the macOS build of the app per Apple requirements 10 | - Under the hood: Update electron from 6.0.7 to 7.0.1 11 | 12 | ## [3.0.0] - 2019-09-04 13 | ### Changed 14 | - No longer prompt Linux users for sudo 15 | - Under the hood: Update electron from 4.0.4 to 6.0.7 16 | - Under the hood: Update spellchecker and related electron dependencies 17 | 18 | ### Fixed 19 | - Change location of dictionary files to the correct directory as specified by Electron, which manifested as a request for sudo on Linux, a JavaScript error on startup, and/or the spellchecker not working 20 | 21 | ## [2.0.0] - 2019-05-26 22 | ### Added 23 | - 32-bit (x86) builds for Windows 24 | - Portable builds for Windows 25 | - KNOWN ISSUE: Portable builds for Windows cannot display system notifications 26 | - Setting to hide sender name and message preview in notifications 27 | - Under the hood: Method for detecting when user logs in or out (auth vs. de-auth) 28 | - Under the hood: System to execute commands as root user (see item under Fixed below) 29 | 30 | ### Changed 31 | - Update icon to match current style of official icon 32 | - Update icon to have a bit more space around the outside (padding) 33 | - Under the hood: Refactor spellchecking dictionary manager logic and error handling 34 | 35 | ### Fixed 36 | - Javascript error on launch for Linux users (resulting from dist dictionaries folder being owned by root--Linux users are now prompted to allow changing ownership of the dictionaries folder to the current user) 37 | 38 | ## [1.0.1] - 2019-04-16 39 | ### Fixed 40 | - Clicking links in text messages now opens them in your browser again instead of doing nothing (big oof) 41 | 42 | ## [1.0.0] - 2019-04-05 43 | ### Changed 44 | - *BREAKING CHANGE* Migrate to new URL provided by Google (messages.android.com -> messages.google.com, requires signing in again) 45 | - Under the hood: Associated changes and fixes relating to URL change 46 | 47 | ## [0.9.1] - 2019-03-03 48 | ### Fixed 49 | - Spell check now works again (abruptly stopped working after the release of 0.9.0 due to new HTTP security header) 50 | 51 | ## [0.9.0] - 2019-02-18 52 | ### Added 53 | - Setting to disable notification sound 54 | - Setting to disable sending message when pressing enter 55 | 56 | ### Changed 57 | - Use inline window buttons on Mac (looks more similar to iMessage) 58 | - Update electron from 3.1.3 to 4.0.4 (see note under Fixed) 59 | - Update README.md 60 | - Under the hood: Method to communicate user settings changes to webview 61 | - Under the hood: Revamp link opening method 62 | - Under the hood: Electron 4-related API changes 63 | - Under the hood: Code cleanup 64 | 65 | ### Fixed 66 | - Localization of Messages page (buttons and text provided by Google) (this appeared to be broken in Electron 3) 67 | - The link to a support page shown when the app can't detect the phone should now open in system web browser like other links 68 | 69 | ## [0.8.0] - 2019-02-12 70 | ### Added 71 | - Spellchecking for various languages (see notes in README) 72 | - Manually refreshing the webview for those times when the app gets all 🤪 (Accessible by pressing Ctrl+R or Cmd+R) 73 | - Full screen toggle item to View menu 74 | 75 | ### Changed 76 | - Update electron from 2.0.12 to 3.1.3 (Electron 3 is required by electron-updater 4 which is required by electron-builder 20) 77 | 78 | ### Fixed 79 | - Location of Check for Updates menu item on Windows (Now under Help) 80 | 81 | ## [0.7.1] - 2018-11-17 82 | ### Changed 83 | - Update electron from 2.0.2 to 2.0.12 84 | 85 | ## [0.7.0] - 2018-07-25 86 | ### Added 87 | - Toggle for tray shortcut to make app visible on Windows (single or double-click, previously there was no preference and the shortcut was double-click) 88 | 89 | ### Changed 90 | - Under the hood: Overhaul communication between main process and webview to faciliate notification customization 91 | 92 | ### Fixed 93 | - Clicking a notification now highlights that conversation (this was working before 0.6.0 and accidentally broken when making app visible on notification click...now clicking shows the app *and* highlights the conversation 🎉) 94 | 95 | ## [0.6.0] - 2018-07-20 96 | ### Added 97 | - Visual indicator (badge) to Windows tray icon when notification comes in 98 | - Link to package for this app on AUR (for Arch Linux users) 99 | 100 | ### Changed 101 | - Clicking notification now makes app visible and focused 102 | - Under the hood: Method of displaying notification (with our bridge/ipc) 103 | - Update README.md 104 | 105 | ### Fixed 106 | - Linux now respects your choice when asking to restart the app 107 | - Typos in README.md corrected 108 | 109 | ## [0.5.0] - 2018-07-17 110 | ### Added 111 | - Setting to start in tray (automatically hide app on start) 112 | - Setting to make tray/menu bar functionality optional 113 | - Preferences on Mac 114 | 115 | ### Changed 116 | - Default to enabling tray only on Windows and Mac 117 | - Refine window minimizing and closing UX further: 118 | - On Windows and Linux, closing window when tray icon is disabled now closes the app entirely 119 | - On Windows and Linux, when the tray icon is disabled, the option to start in tray is disabled 120 | - Make certain UI language more platform-specific 121 | - KNOWN ISSUE: Toggling the tray from on to off while using Linux requires an app restart for now 122 | - Refactor some tray code into a new class to manage it, TrayManager 123 | 124 | ### Fixed 125 | - Mac tray (menu bar) icon now inverts correctly when selected or Finder is in dark mode 126 | 127 | ## [0.4.0] - 2018-07-14 128 | ### Added 129 | - Right-click context menu with support for cut/copy/paste/undo/redo/save image/save video 130 | - Builds for pacman package manager (used by Arch Linux and related distros) 131 | - Changelog (with shortcut to changelog in Help menu) 132 | 133 | ### Changed 134 | - Update README.md 135 | - On launch, open dev tools for the webview when in dev mode 136 | 137 | ### Fixed 138 | - App icon not showing or showing sporadically on Linux 139 | 140 | ### Removed 141 | - Some dead code/comments 142 | 143 | ## [0.3.0] - 2018-07-08 144 | ### Added 145 | - Tray icon support for macOS and Linux 146 | - Show/hide toggle to tray context menu 147 | - File menu with items to manually check for updates and quit the app 148 | - Standard Window menu provided by electron (with proper minimize/hide items and keyboard shortcuts) 149 | - One-time notification about minimizing to tray on Windows 150 | - Build scripts to only build instead of building and attempting to publish a release 151 | 152 | ### Changed 153 | - Minimize/close behavior on Windows and Linux (minimizing now minimizes, closing now minimizes to tray) 154 | - Refactor menu code 155 | 156 | ### Fixed 157 | - Command+H app hiding behavior on macOS (now defocuses app when hiding window) 158 | 159 | ## [0.2.0] - 2018-07-05 160 | ### Added 161 | - Setting to auto-hide menu bar (and toggle its appearance via the standard Alt+H shortcut) on Windows and Linux 162 | - electron-settings dependency for managing the above and future user settings 163 | - Screenshots of Windows tray and macOS dock functionality 164 | 165 | ### Changed 166 | - Update README.md 167 | 168 | ### Removed 169 | - "Hello World" code and unit/e2e tests from boilerplate 170 | 171 | ## [0.1.0] - 2018-06-27 172 | ### Added 173 | - Notification count badge in dock on macOS (clears on window focus/app.activate) 174 | - Tray icon and minimizing to tray for Windows 175 | - Command+H shortcut to hide app on macOS 176 | 177 | ### Changed 178 | - Closing window on macOS now doesn't quit app (expected UX on macOS) 179 | - Prevent multiple instances of app being able to launch (for example, when minimized to tray on Windows without pinning to taskbar, then clicking a shortcut from the Start menu) 180 | - Update README.md 181 | 182 | ## [0.0.5] - 2018-06-26 183 | ### Changed 184 | - Update README.md 185 | - Update shape of chat bubble in icon 186 | - Use different combination of scripts to generate icons 187 | 188 | ### Fixed 189 | - Corrupt icons in Windows Taskbar and macOS Spotlight 190 | 191 | ## [0.0.4] - 2018-06-24 192 | ### Changed 193 | - README.md even more complete 194 | 195 | ### Fixed 196 | - Hyperlinks in text messages now open in system default browser when clicked 197 | 198 | ## [0.0.3] - 2018-06-22 199 | ### Changed 200 | - Nothing besides the version number, just created this version to test auto-update functionality 201 | 202 | ## [0.0.2] - 2018-06-22 203 | ### Added 204 | - Signed app binary for macOS 205 | - Notifications on Windows 206 | - Builds for various Linux distros/package managers 207 | - A real icon 208 | - Auto-update mechanism via electron-updater 209 | - TODOs 210 | 211 | ### Changed 212 | - README.md more complete 213 | - package.json more complete 214 | - Values and code elements from boilerplate updates 215 | - Automatically pop-up dev tools in dev mode 216 | - Generate icons via a script 217 | 218 | ## 0.0.1 - 2018-06-21 219 | ### Added 220 | - Project files (initial release) 221 | 222 | ### Changed 223 | - It works! (I think hope) 224 | - No Linux binary, no signing certs for Mac/Windows, no actual icon...but it's a start. 225 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Like any other open source projects, there are multiple ways to contribute to this project: 4 | 5 | * As a developer, depending on your skills and experience, 6 | * As a user who enjoys the project and wants to help. 7 | 8 | ##### Reporting Bugs 9 | 10 | If you found something broken or not working properly, feel free to create an issue in Github with as much information as possible, such as logs and how to reproduce the problem. Before opening the issue, make sure that: 11 | 12 | * You have read this documentation, 13 | * You are using the latest version of project, 14 | * You already searched other issues to see if your problem or request was already reported. 15 | 16 | ##### Improving the Documentation 17 | 18 | You can improve this documentation by forking its repository, updating the content and sending a pull request. 19 | 20 | 21 | #### We ❤️ Pull Requests 22 | 23 | A pull request does not need to be a fix for a bug or implementing something new. Software can always be improved, legacy code removed and tests are always welcome! 24 | 25 | Please do not be afraid of contributing code, make sure it follows these rules: 26 | 27 | * Your code compiles, does not break any of the existing code in the master branch and does not cause conflicts, 28 | * The code is readable and has comments, that aren’t superfluous or unnecessary, 29 | * An overview or context is provided as body of the Pull Request. It does not need to be too extensive. 30 | 31 | Extra points if your code comes with tests! 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Chris Knepper 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Messages™ Desktop Android Messages Desktop logo 2 | 3 | Run Android Messages as a desktop app, a la iMessage. For those of us that prefer not to have a browser tab always open for this sort of thing. 4 | 5 | **Not affiliated with Google in any way. Android is a trademark of Google LLC.** 6 | 7 | Android Messages Desktop Windows screenshot 8 | Android Messages Desktop macOS screenshot 9 | Android Messages Desktop tray icon in Windows 10 | Android Messages Desktop notification badge in macOS 11 | 12 | ### Disclaimer: I have tested this with my Pixel on both macOS High Sierra and Windows 10. Normal functionality seems to work, but help testing and feedback is greatly appreciated! 13 | 14 | Inspired by: 15 | 16 | * [Google Play Music Desktop Player](https://github.com/MarshallOfSound/Google-Play-Music-Desktop-Player-UNOFFICIAL-) 17 | * [a Reddit post on r/Android](https://www.reddit.com/r/Android/comments/8shv6q/web_messages/e106a8r/) 18 | 19 | Based on: 20 | 21 | * [electron-boilerplate](https://github.com/szwacz/electron-boilerplate) 22 | 23 | # Download 24 | Head over to the [latest releases](https://github.com/chrisknepper/android-messages-desktop/releases/latest) page! 25 | * For Mac, choose the **dmg** 26 | * For Windows, choose the **exe** 27 | * For Linux, choose either the **deb**, the **snap**, the **pacman**, or the **AppImage**. If you're using Arch or derivates of, it's also available in the [AUR](https://aur.archlinux.org/packages/android-messages-desktop/). 28 | 29 | **Important note:** The Windows app binary isn't signed. This doesn't seem to be a big problem, but please report any issues you run into on Windows that may be related to signing. 30 | 31 | **Important note 2:** We currently have builds for Windows and macOS, and Linux. I test releases on macOS, Windows 10, and Ubuntu Linux. I would love help testing on additional distros of Linux and other versions of Windows. 32 | 33 | # Features 34 | * System notifications when a text comes in 35 | * Notification badges on macOS 36 | * Spellchecking in ~50 languages 37 | * Run in background on Windows / Linux / macOS 38 | * Minimize to tray on Windows / Linux 39 | * Menu bar support on macOS 40 | * TBD... 41 | 42 | # Spellchecking 43 | Implemented via the amazing [`electron-hunspell`](https://github.com/kwonoj/electron-hunspell) library with dictionaries provided by the excellent [`dictionaries`](https://github.com/wooorm/dictionaries) project. Language files are downloaded when the app opens and the language used is based on the language set in your operating system. If you switch your system language and restart the app, the spellchecking should occur in the new language as long as it is in the [list of supported languages](https://github.com/wooorm/dictionaries#table-of-dictionaries). 44 | 45 | # TODOs / Roadmap (rough order of priority): 46 | - [x] Make sure it actually works (definitely works as of v0.1.0, done via [8068ed2](../../commit/8068ed2)) 47 | - [x] Release signed binaries for macOS (binaries are signed as of v0.0.2, done via [8492023](../../commit/8492023)) 48 | - [x] Make an icon (done via [df625ba](../../commit/df625ba)) 49 | - [x] Remove left-over code from electron-boilerplate (done via [4e7638a](../../commit/4e7638a)) 50 | - [ ] Correct tests 51 | - [x] Release packages for Linux (done via [41ed205](../../commit/41ed205)) 52 | - [x] Handling updates (done via [625bf6d](../../commit/625bf6d)) 53 | - [x] Platform-specific UX enhancements (i.e. badges in macOS dock) (this specific enhancement is in as of v0.1.0, done via [8068ed2](../../commit/8068ed2)) **UX enhancements and features are now being worked on with issues submitted by users** 54 | - [ ] Release signed binaries for Windows 55 | - [ ] Make a website? (if it gets popular enough) 56 | - [ ] Support customization/custom options a la Google Play Music Desktop Player? 57 | 58 | # Development 59 | Make sure you have [Node.js](https://nodejs.org) installed, then run the following in your terminal: 60 | 61 | ``` 62 | git clone https://github.com/chrisknepper/android-messages-desktop.git 63 | cd android-messages-desktop 64 | npm install 65 | npm start 66 | ``` 67 | 68 | ## Starting the app in development mode 69 | ``` 70 | npm start 71 | ``` 72 | 73 | # Testing 74 | Run all tests: 75 | ``` 76 | npm test 77 | ``` 78 | 79 | ## Unit 80 | ``` 81 | npm run unit 82 | ``` 83 | Using [electron-mocha](https://github.com/jprichardson/electron-mocha) test runner with the [Chai](http://chaijs.com/api/assert/) assertion library. You can put your spec files wherever you want within the `src` directory, just name them with the `.spec.js` extension. 84 | 85 | ## End to end 86 | ``` 87 | npm run e2e 88 | ``` 89 | Using [Mocha](https://mochajs.org/) and [Spectron](http://electron.atom.io/spectron/). This task will run all files in `e2e` directory with `.e2e.js` extension. 90 | 91 | # Publishing a release: 92 | 1. Commit what you want to go in the release (including updates to README and CHANGELOG) 93 | 2. Run `npm version ` where `` is either `major`, `minor`, or `patch` depending on the extent of your changes (this command increments the version in package.json and creates a git tag for the new version) 94 | 3. Run `git push` 95 | 4. Run `git push --tags` 96 | 5. Run `npm run release` (for this step to succeed, you must have a GitHub Personal Access Token with write access to this repository in your `PATH` as `GH_TOKEN`) 97 | 6. Go to GitHub and publish the release (which should be there as a draft), taking care to make the release name match the tag name including the "v" 98 | 99 | Once the packaging process finished, the `dist` directory will contain your distributable file. 100 | 101 | We use [electron-builder](https://github.com/electron-userland/electron-builder) to handle the packaging process. It has a lot of [customization options](https://www.electron.build/configuration/configuration), which you can declare under `"build"` key in `package.json`. 102 | 103 | # The icons 104 | We use [png2icons](https://www.npmjs.com/package/png2icons) to create Windows and Mac icons from the source PNG icon, which is located in [assets/android_messages_desktop_icon.png](assets/android_messages_desktop_icon.png). However, the Windows icon generated from this package seems to result in visual corruption on Windows 10, so I'm manually converting the PNG to a Windows icon with [icoconvert.com](http://icoconvert.com) for the time being. 105 | -------------------------------------------------------------------------------- /app/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Android Messages 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/android_messages_desktop_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/assets/android_messages_desktop_icon.png -------------------------------------------------------------------------------- /assets/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/assets/screenshots/1.png -------------------------------------------------------------------------------- /assets/screenshots/mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/assets/screenshots/mac.png -------------------------------------------------------------------------------- /assets/screenshots/mac_notification_badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/assets/screenshots/mac_notification_badge.png -------------------------------------------------------------------------------- /assets/screenshots/windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/assets/screenshots/windows.png -------------------------------------------------------------------------------- /assets/screenshots/windows_tray_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/assets/screenshots/windows_tray_icon.png -------------------------------------------------------------------------------- /build/start.js: -------------------------------------------------------------------------------- 1 | const childProcess = require("child_process"); 2 | const electron = require("electron"); 3 | const webpack = require("webpack"); 4 | const config = require("./webpack.app.config"); 5 | 6 | const env = "development"; 7 | const compiler = webpack(config(env)); 8 | let electronStarted = false; 9 | 10 | const watching = compiler.watch({}, (err, stats) => { 11 | if (!err && !stats.hasErrors() && !electronStarted) { 12 | electronStarted = true; 13 | 14 | childProcess 15 | .spawn(electron, ["."], { stdio: "inherit" }) 16 | .on("close", () => { 17 | watching.close(); 18 | }); 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /build/webpack.app.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const merge = require("webpack-merge"); 3 | const base = require("./webpack.base.config"); 4 | 5 | module.exports = env => { 6 | return merge(base(env), { 7 | entry: { 8 | background: "./src/background.js", 9 | app: "./src/app.js", 10 | bridge: "./src/helpers/webview/bridge.js" 11 | }, 12 | output: { 13 | filename: "[name].js", 14 | path: path.resolve(__dirname, "../app") 15 | } 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /build/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const nodeExternals = require("webpack-node-externals"); 3 | const FriendlyErrorsWebpackPlugin = require("friendly-errors-webpack-plugin"); 4 | 5 | const translateEnvToMode = (env) => { 6 | if (env === "production") { 7 | return "production"; 8 | } 9 | return "development"; 10 | }; 11 | 12 | module.exports = env => { 13 | return { 14 | target: "electron-renderer", 15 | mode: translateEnvToMode(env), 16 | node: { 17 | __dirname: false, 18 | __filename: false 19 | }, 20 | externals: [nodeExternals()], 21 | resolve: { 22 | alias: { 23 | env: path.resolve(__dirname, `../config/env_${env}.json`) 24 | } 25 | }, 26 | devtool: "source-map", 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.js$/, 31 | exclude: /node_modules/, 32 | use: ["babel-loader"] 33 | }, 34 | { 35 | test: /\.css$/, 36 | use: ["style-loader", "css-loader"] 37 | }, 38 | { 39 | test: /\.(png|jpg|gif)$/, 40 | use: [ 41 | { 42 | loader: 'file-loader', 43 | options: { 44 | useRelativePath: process.env.NODE_ENV !== "production", 45 | emitFile: false, 46 | name (file) { 47 | if (process.env.NODE_ENV !== "production") { 48 | return '[name].[ext]' 49 | } 50 | return '[name].[ext]' 51 | } 52 | } 53 | } 54 | ] 55 | } 56 | ] 57 | }, 58 | plugins: [ 59 | new FriendlyErrorsWebpackPlugin({ clearConsole: env === "development" }) 60 | ] 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /build/webpack.e2e.config.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge"); 2 | const jetpack = require("fs-jetpack"); 3 | const base = require("./webpack.base.config"); 4 | 5 | // Test files are scattered through the whole project. Here we're searching 6 | // for them and generating entry file for webpack. 7 | 8 | const e2eDir = jetpack.cwd("e2e"); 9 | const tempDir = jetpack.cwd("temp"); 10 | const entryFilePath = tempDir.path("e2e_entry.js"); 11 | 12 | const entryFileContent = e2eDir 13 | .find({ matching: "*.e2e.js" }) 14 | .reduce((fileContent, path) => { 15 | const normalizedPath = path.replace(/\\/g, "/"); 16 | return `${fileContent}import "../e2e/${normalizedPath}";\n`; 17 | }, ""); 18 | 19 | jetpack.write(entryFilePath, entryFileContent); 20 | 21 | module.exports = env => { 22 | return merge(base(env), { 23 | entry: entryFilePath, 24 | output: { 25 | filename: "e2e.js", 26 | path: tempDir.path() 27 | } 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /build/webpack.unit.config.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge"); 2 | const jetpack = require("fs-jetpack"); 3 | const base = require("./webpack.base.config"); 4 | 5 | // Test files are scattered through the whole project. Here we're searching 6 | // for them and generating entry file for webpack. 7 | 8 | const srcDir = jetpack.cwd("src"); 9 | const tempDir = jetpack.cwd("temp"); 10 | const entryFilePath = tempDir.path("specs_entry.js"); 11 | 12 | const entryFileContent = srcDir 13 | .find({ matching: "*.spec.js" }) 14 | .reduce((fileContent, path) => { 15 | const normalizedPath = path.replace(/\\/g, "/"); 16 | return `${fileContent}import "../src/${normalizedPath}";\n`; 17 | }, ""); 18 | 19 | jetpack.write(entryFilePath, entryFileContent); 20 | 21 | module.exports = env => { 22 | return merge(base(env), { 23 | entry: entryFilePath, 24 | output: { 25 | filename: "specs.js", 26 | path: tempDir.path() 27 | } 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /config/env_development.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "development", 3 | "description": "Add here any environment specific stuff you like." 4 | } 5 | -------------------------------------------------------------------------------- /config/env_production.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "production", 3 | "description": "Add here any environment specific stuff you like." 4 | } 5 | -------------------------------------------------------------------------------- /config/env_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "description": "Add here any environment specific stuff you like." 4 | } 5 | -------------------------------------------------------------------------------- /config/packaging/macosEntitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /config/packaging/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require('electron-notarize'); 2 | 3 | exports.default = async function notarizing(context) { 4 | const { electronPlatformName, appOutDir } = context; 5 | if (electronPlatformName !== 'darwin') { 6 | return; 7 | } 8 | 9 | const appName = context.packager.appInfo.productFilename; 10 | 11 | return await notarize({ 12 | appBundleId: 'com.knepper.android-messages-desktop', 13 | appPath: `${appOutDir}/${appName}.app`, 14 | appleId: process.env.ANDROID_MESSAGES_APPLE_ID_EMAIL, 15 | appleIdPassword: process.env.ANDROID_MESSAGES_APPLE_ID_APP_PASSWORD, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /e2e/utils.js: -------------------------------------------------------------------------------- 1 | import electron from "electron"; 2 | import { Application } from "spectron"; 3 | 4 | const beforeEach = function() { 5 | this.timeout(10000); 6 | this.app = new Application({ 7 | path: electron, 8 | args: ["."], 9 | startTimeout: 10000, 10 | waitTimeout: 10000 11 | }); 12 | return this.app.start(); 13 | }; 14 | 15 | const afterEach = function() { 16 | this.timeout(10000); 17 | if (this.app && this.app.isRunning()) { 18 | return this.app.stop(); 19 | } 20 | return undefined; 21 | }; 22 | 23 | export default { 24 | beforeEach, 25 | afterEach 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "android-messages-desktop", 3 | "productName": "Android Messages", 4 | "description": "Messages for web, as a desktop app", 5 | "version": "3.1.0", 6 | "author": "Chris Knepper ", 7 | "copyright": "© 2018-2019 Chris Knepper", 8 | "homepage": "https://github.com/chrisknepper/android-messages-desktop", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/chrisknepper/android-messages-desktop.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/chrisknepper/android-messages-desktop/issues" 15 | }, 16 | "main": "app/background.js", 17 | "build": { 18 | "appId": "com.knepper.android-messages-desktop", 19 | "files": [ 20 | "app/**/*", 21 | "resources/**/*", 22 | "node_modules/**/*", 23 | "package.json" 24 | ], 25 | "directories": { 26 | "buildResources": "resources" 27 | }, 28 | "extraResources": [ 29 | "resources/dictionaries" 30 | ], 31 | "afterSign": "config/packaging/notarize.js", 32 | "mac": { 33 | "category": "public.app-category.social-networking", 34 | "target": [ 35 | "zip", 36 | "dmg" 37 | ], 38 | "entitlements": "config/packaging/macosEntitlements.plist", 39 | "entitlementsInherit": "config/packaging/macosEntitlements.plist" 40 | }, 41 | "win": { 42 | "target": [ 43 | { 44 | "target": "nsis", 45 | "arch": [ 46 | "x64", 47 | "ia32" 48 | ] 49 | }, 50 | { 51 | "target": "portable", 52 | "arch": [ 53 | "x64", 54 | "ia32" 55 | ] 56 | } 57 | ] 58 | }, 59 | "portable": { 60 | "artifactName": "${productName} Portable ${version}.${ext}" 61 | }, 62 | "linux": { 63 | "category": "Chat", 64 | "target": [ 65 | "deb", 66 | "AppImage", 67 | "snap", 68 | "pacman" 69 | ] 70 | } 71 | }, 72 | "scripts": { 73 | "postinstall": "electron-builder install-app-deps", 74 | "preunit": "webpack --config=build/webpack.unit.config.js --env=test --display=none", 75 | "unit": "electron-mocha temp/specs.js --renderer --require source-map-support/register", 76 | "pree2e": "webpack --config=build/webpack.app.config.js --env=test --display=none && webpack --config=build/webpack.e2e.config.js --env=test --display=none", 77 | "e2e": "mocha temp/e2e.js --require source-map-support/register", 78 | "test": "npm run unit && npm run e2e", 79 | "start": "node build/start.js", 80 | "release": "webpack --config=build/webpack.app.config.js --env=production && electron-builder -mwl", 81 | "build": "webpack --config=build/webpack.app.config.js --env=production && electron-builder --publish never", 82 | "build-all": "webpack --config=build/webpack.app.config.js --env=production && electron-builder -mwl --publish never", 83 | "generate-icons": "png2icons assets/android_messages_desktop_icon.png resources/icon -all -i" 84 | }, 85 | "dependencies": { 86 | "about-window": "1.13.0", 87 | "electron-hunspell": "1.0.0-beta.12", 88 | "electron-settings": "3.2.0", 89 | "electron-updater": "4.2.0", 90 | "fs-jetpack": "^1.0.0" 91 | }, 92 | "devDependencies": { 93 | "@babel/core": "7.5.5", 94 | "@babel/preset-env": "7.5.5", 95 | "babel-loader": "8.0.6", 96 | "babel-plugin-transform-object-rest-spread": "^7.0.0-beta.3", 97 | "chai": "^4.1.0", 98 | "css-loader": "^0.28.7", 99 | "electron": "7.0.1", 100 | "electron-builder": "21.2.0", 101 | "electron-mocha": "^6.0.4", 102 | "electron-notarize": "^0.2.0", 103 | "file-loader": "^1.1.11", 104 | "friendly-errors-webpack-plugin": "^1.6.1", 105 | "mocha": "^5.2.0", 106 | "png2icons": "^1.0.1", 107 | "source-map-support": "^0.5.0", 108 | "spectron": "^3.7.2", 109 | "style-loader": "^0.21.0", 110 | "webpack": "^4.12.0", 111 | "webpack-cli": "^3.0.4", 112 | "webpack-merge": "^4.1.0", 113 | "webpack-node-externals": "^1.6.0" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /resources/dictionaries/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | 6 | # This, along with the extraFiles electron-builder directive in package.json ensures that 7 | # resources/dictionaries folder is included as an empty folder in production/user-facing builds. 8 | # It must be done this way because some OSes install the app to a location from which the user does not 9 | # have the permission to create a subdirectory unless they run the app as root. Namely, Ubuntu, via the .deb, 10 | # installs the app to /opt/ and calling node mkdir only works as root from there. 11 | -------------------------------------------------------------------------------- /resources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icon.icns -------------------------------------------------------------------------------- /resources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icon.ico -------------------------------------------------------------------------------- /resources/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/1024x1024.png -------------------------------------------------------------------------------- /resources/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/128x128.png -------------------------------------------------------------------------------- /resources/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/16x16.png -------------------------------------------------------------------------------- /resources/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/24x24.png -------------------------------------------------------------------------------- /resources/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/256x256.png -------------------------------------------------------------------------------- /resources/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/32x32.png -------------------------------------------------------------------------------- /resources/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/48x48.png -------------------------------------------------------------------------------- /resources/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/512x512.png -------------------------------------------------------------------------------- /resources/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/icons/64x64.png -------------------------------------------------------------------------------- /resources/tray/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/tray/icon.png -------------------------------------------------------------------------------- /resources/tray/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/tray/icon@2x.png -------------------------------------------------------------------------------- /resources/tray/icon_macTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/tray/icon_macTemplate.png -------------------------------------------------------------------------------- /resources/tray/icon_macTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/tray/icon_macTemplate@2x.png -------------------------------------------------------------------------------- /resources/tray/tray_with_badge.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisknepper/android-messages-desktop/b3abdcddc288e7aace0be452713a4949f1c8348f/resources/tray/tray_with_badge.ico -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import './stylesheets/main.css'; 2 | 3 | import { ipcRenderer, remote } from 'electron'; 4 | import { EVENT_UPDATE_USER_SETTING, IS_DEV, IS_MAC } from './constants'; 5 | 6 | const state = { 7 | loaded: false 8 | }; 9 | 10 | const app = remote.app; 11 | 12 | androidMessagesWebview.addEventListener('did-start-loading', () => { 13 | // Intercept request for notifications and accept it 14 | androidMessagesWebview.getWebContents().session.setPermissionRequestHandler((webContents, permission, callback) => { 15 | const url = webContents.getURL(); 16 | 17 | if (permission === 'notifications') { 18 | /* 19 | * We always get a "notification" when the app starts due to calling setPermissionRequestHandler, 20 | * which accepts the permission to send browser notifications on behalf of the user. 21 | * This "notification" should fire before we start listening for notifications, 22 | * and should not cause problems. 23 | * TODO: Move this to a helper 24 | * TODO: Provide visual indicators for Linux, could set window (taskbar) icon, may also do for Windows 25 | */ 26 | 27 | return callback(false); // Prevent the webview's notification from coming through (we roll our own) 28 | } 29 | 30 | if (!url.startsWith('https://messages.google.com/web')) { 31 | return callback(false); // Deny 32 | } 33 | }); 34 | 35 | androidMessagesWebview.getWebContents().session.webRequest.onHeadersReceived({ 36 | // Only run this code on requests for which the URL is in the following array. 37 | // The SRC of the webview is the same context as the preload script. 38 | urls: ['https://messages.google.com/web/'] }, (details, callback) => { 39 | /* 40 | * Google, prior to changing the URL of the app from messages.android.com to messages.google.com/web sends several directives in the 41 | * content-security-policy header which restrict what kind of JS can run and where it can originate. This can break our spell 42 | * checking (because the spellchecker instantiates a WebAssembly module) unless we include unsafe-eval for the root page headers. 43 | * We must do this before any stricter rules are specified since they can only "further restrict capabilities" as they are defined. 44 | * We therefore must modify the rule Google sends by detecting and prepending the next-least-strict rule sent, "unsafe-inline." 45 | * We must use double quotes since content-security-policy directive rules need single quotes as part of the string. 46 | * 47 | * Doing it this way allows us to keep the rest of Google's security rules to maximize security while still allowing WebAssembly to work. 48 | * 49 | * If this ever stops working, we can force WebAssembly to work by completely nixing the content-security-policy header, done via: 50 | * delete modifiedHeaders['content-security-policy']; 51 | * 52 | * See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#Multiple_content_security_policies 53 | */ 54 | const modifiedHeaders = { 55 | ...details.responseHeaders 56 | }; 57 | 58 | // Since the URL change, this header no longer seems to be sent, so this should allow spellchecking to work, 59 | // even if Google starts sending the header again. 60 | if (typeof modifiedHeaders === 'object' && 'content-security-policy' in modifiedHeaders) { 61 | const firstCSP = modifiedHeaders['content-security-policy'][0]; 62 | 63 | if (firstCSP.includes("'unsafe-inline'")) { 64 | modifiedHeaders['content-security-policy'][0] = firstCSP.replace("'unsafe-inline'", "'unsafe-eval' 'unsafe-inline'"); 65 | } 66 | } 67 | 68 | callback({ 69 | responseHeaders: modifiedHeaders 70 | }); 71 | }); 72 | }); 73 | 74 | androidMessagesWebview.addEventListener('did-finish-load', () => { // just before onLoad 75 | console.log('finished loading'); 76 | 77 | }); 78 | 79 | androidMessagesWebview.addEventListener('did-stop-loading', () => { // coincident with onLoad, can fire multiple times 80 | console.log('done loading'); 81 | if (!state.loaded) { 82 | state.loaded = true; 83 | loader.classList.add('hidden'); 84 | if (IS_DEV) { 85 | androidMessagesWebview.getWebContents().openDevTools(); 86 | } 87 | app.mainWindow.on('focus', () => { 88 | // Make sure the webview gets a focus event on its window/DOM when the app window does, 89 | // this makes automatic text input focus work. 90 | androidMessagesWebview.dispatchEvent(new Event('focus')); 91 | }); 92 | } 93 | 94 | }); 95 | 96 | androidMessagesWebview.addEventListener('dom-ready', () => { 97 | console.log('dom ready'); 98 | //Notification.requestPermission(); // Could be necessary for initial notification, need to test 99 | 100 | // Make the title centered so that it won't get weirdly covered by the traffic light on mac 101 | // 10px should make it look roughly centered 102 | // TODO: Use more sophisticated CSS which doesn't rely on Google's obfuscated class names to do this 103 | if (IS_MAC) { 104 | androidMessagesWebview.insertCSS('.main-nav-header .logo {text-align:center; transform: translateX(10px)}'); 105 | } 106 | }); 107 | 108 | // Forward event from main process to webview bridge 109 | ipcRenderer.on(EVENT_UPDATE_USER_SETTING, (event, settingsList) => { 110 | androidMessagesWebview.getWebContents().send(EVENT_UPDATE_USER_SETTING, settingsList); 111 | }); 112 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | // This is main process of Electron, started as first thing when your 2 | // app starts. It runs through entire life of your application. 3 | // It doesn't have any windows which you can see on screen, but we can open 4 | // window from here. 5 | 6 | import path from 'path'; 7 | import url from 'url'; 8 | import { app, Menu, ipcMain, Notification, shell, nativeTheme } from 'electron'; 9 | import { autoUpdater } from 'electron-updater'; 10 | import { baseMenuTemplate } from './menu/base_menu_template'; 11 | import { devMenuTemplate } from './menu/dev_menu_template'; 12 | import { settingsMenu } from './menu/settings_menu_template'; 13 | import { helpMenuTemplate } from './menu/help_menu_template'; 14 | import createWindow from './helpers/window'; 15 | import DictionaryManager from './helpers/dictionary_manager'; 16 | import TrayManager from './helpers/tray/tray_manager'; 17 | import settings from 'electron-settings'; 18 | import { IS_MAC, IS_WINDOWS, IS_LINUX, IS_DEV, SETTING_TRAY_ENABLED, SETTING_TRAY_CLICK_SHORTCUT, SETTING_CUSTOM_WORDS, EVENT_WEBVIEW_NOTIFICATION, EVENT_NOTIFICATION_REFLECT_READY, EVENT_BRIDGE_INIT, EVENT_SPELL_ADD_CUSTOM_WORD, EVENT_SPELLING_REFLECT_READY, EVENT_UPDATE_USER_SETTING } from './constants'; 19 | 20 | // Special module holding environment variables which you declared 21 | // in config/env_xxx.json file. 22 | import env from 'env'; 23 | 24 | const state = { 25 | unreadNotificationCount: 0, 26 | notificationSoundEnabled: true, 27 | notificationContentHidden: false, 28 | bridgeInitDone: false, 29 | useSystemDarkMode: true 30 | }; 31 | 32 | let mainWindow = null; 33 | 34 | // Prevent multiple instances of the app which causes many problems with an app like ours 35 | // Without this, if an instance were minimized to the tray in Windows, clicking a shortcut would launch another instance, icky 36 | // Adapted from https://github.com/electron/electron/blob/v4.0.4/docs/api/app.md#apprequestsingleinstancelock 37 | const isFirstInstance = app.requestSingleInstanceLock(); 38 | 39 | if (!isFirstInstance) { 40 | app.quit(); 41 | } else { 42 | app.on('second-instance', (event, commandLine, workingDirectory) => { 43 | if (mainWindow) { 44 | if (!mainWindow.isVisible()) { 45 | mainWindow.show(); 46 | } 47 | } 48 | }) 49 | 50 | let trayManager = null; 51 | 52 | const setApplicationMenu = () => { 53 | const menus = baseMenuTemplate; 54 | if (env.name !== 'production') { 55 | menus.push(devMenuTemplate); 56 | } 57 | menus.push(helpMenuTemplate); 58 | Menu.setApplicationMenu(Menu.buildFromTemplate(menus)); 59 | }; 60 | 61 | // Save userData in separate folders for each environment. 62 | // Thanks to this you can use production and development versions of the app 63 | // on same machine like those are two separate apps. 64 | if (env.name !== 'production') { 65 | const userDataPath = app.getPath('userData'); 66 | app.setPath('userData', `${userDataPath} (${env.name})`); 67 | } 68 | 69 | if (IS_WINDOWS) { 70 | // Stupid, DUMB calls that have to be made to let notifications come through on Windows (only Windows 10?) 71 | // See: https://github.com/electron/electron/issues/10864#issuecomment-382519150 72 | app.setAppUserModelId('com.knepper.android-messages-desktop'); 73 | app.setAsDefaultProtocolClient('android-messages-desktop'); 74 | } 75 | 76 | app.on('ready', () => { 77 | trayManager = new TrayManager(); 78 | 79 | // TODO: Create a preference manager which handles all of these 80 | const autoHideMenuBar = settings.get('autoHideMenuPref', false); 81 | const startInTray = settings.get('startInTrayPref', false); 82 | const notificationSoundEnabled = settings.get('notificationSoundEnabledPref', true); 83 | const pressEnterToSendEnabled = settings.get('pressEnterToSendPref', true); 84 | const hideNotificationContent = settings.get('hideNotificationContentPref', false); 85 | const useSystemDarkMode = settings.get('useSystemDarkModePref', true); 86 | settings.watch(SETTING_TRAY_ENABLED, trayManager.handleTrayEnabledToggle); 87 | settings.watch(SETTING_TRAY_CLICK_SHORTCUT, trayManager.handleTrayClickShortcutToggle); 88 | settings.watch('notificationSoundEnabledPref', (newValue) => { 89 | state.notificationSoundEnabled = newValue; 90 | }); 91 | settings.watch('pressEnterToSendPref', (newValue) => { 92 | mainWindow.webContents.send(EVENT_UPDATE_USER_SETTING, { 93 | enterToSend: newValue 94 | }); 95 | }); 96 | settings.watch('hideNotificationContentPref', (newValue) => { 97 | state.notificationContentHidden = newValue; 98 | }); 99 | settings.watch('useSystemDarkModePref', (newValue) => { 100 | state.useSystemDarkMode = newValue; 101 | }); 102 | 103 | setApplicationMenu(); 104 | const menuInstance = Menu.getApplicationMenu(); 105 | 106 | if (IS_MAC) { 107 | app.on('activate', () => { 108 | mainWindow.show(); 109 | }); 110 | } 111 | 112 | nativeTheme.on('updated', () => { 113 | if (state.useSystemDarkMode) { 114 | mainWindow.webContents.send(EVENT_UPDATE_USER_SETTING, { 115 | useDarkMode: nativeTheme.shouldUseDarkColors 116 | }); 117 | } 118 | }); 119 | 120 | const trayMenuItem = menuInstance.getMenuItemById('startInTrayMenuItem'); 121 | const enableTrayIconMenuItem = menuInstance.getMenuItemById('enableTrayIconMenuItem'); 122 | const notificationSoundEnabledMenuItem = menuInstance.getMenuItemById('notificationSoundEnabledMenuItem'); 123 | const pressEnterToSendMenuItem = menuInstance.getMenuItemById('pressEnterToSendMenuItem'); 124 | const hideNotificationContentMenuItem = menuInstance.getMenuItemById('hideNotificationContentMenuItem'); 125 | const useSystemDarkModeMenuItem = menuInstance.getMenuItemById('useSystemDarkModeMenuItem'); 126 | 127 | if (!IS_MAC) { 128 | // Sets checked status based on user prefs 129 | menuInstance.getMenuItemById('autoHideMenuBarMenuItem').checked = autoHideMenuBar; 130 | trayMenuItem.enabled = trayManager.enabled; 131 | } 132 | 133 | trayMenuItem.checked = startInTray; 134 | enableTrayIconMenuItem.checked = trayManager.enabled; 135 | 136 | if (IS_WINDOWS) { 137 | const trayClickShortcutMenuItem = menuInstance.getMenuItemById('trayClickShortcutMenuItem'); 138 | trayClickShortcutMenuItem.enabled = trayManager.enabled; 139 | // As of Electron 3 or 4, setting checked property (even to false) of multiple items in radio group results in 140 | // the first one always being checked, so we have to set it just on the one where checked should == true 141 | const checkedItemIndex = (trayManager.clickShortcut === 'double-click') ? 0 : 1; 142 | trayClickShortcutMenuItem.submenu.items[checkedItemIndex].checked = true; 143 | } 144 | 145 | notificationSoundEnabledMenuItem.checked = notificationSoundEnabled; 146 | pressEnterToSendMenuItem.checked = pressEnterToSendEnabled; 147 | hideNotificationContentMenuItem.checked = hideNotificationContent; 148 | useSystemDarkModeMenuItem.checked = useSystemDarkMode; 149 | 150 | state.notificationSoundEnabled = notificationSoundEnabled; 151 | state.notificationContentHidden = hideNotificationContent; 152 | state.useSystemDarkMode = useSystemDarkMode; 153 | 154 | autoUpdater.checkForUpdatesAndNotify(); 155 | 156 | const mainWindowOptions = { 157 | width: 1100, 158 | height: 800, 159 | autoHideMenuBar: autoHideMenuBar, 160 | show: !(startInTray), //Starts in tray if set 161 | titleBarStyle: IS_MAC ? 'hiddenInset' : 'default', //Turn on hidden frame on a Mac 162 | webPreferences: { 163 | contextIsolation: false, 164 | nodeIntegration: true, 165 | webviewTag: true 166 | } 167 | }; 168 | 169 | if (IS_LINUX) { 170 | // Setting the icon in Linux tends to be finicky without explicitly setting it like this. 171 | // See: https://github.com/electron/electron/issues/6205 172 | mainWindowOptions.icon = path.join(__dirname, '..', 'resources', 'icons', '128x128.png'); 173 | }; 174 | 175 | mainWindow = createWindow('main', mainWindowOptions); 176 | 177 | mainWindow.loadURL( 178 | url.format({ 179 | pathname: path.join(__dirname, 'app.html'), 180 | protocol: 'file:', 181 | slashes: true 182 | }) 183 | ); 184 | 185 | trayManager.startIfEnabled(); 186 | 187 | app.mainWindow = mainWindow; // Quick and dirty way for renderer process to access mainWindow for communication 188 | 189 | mainWindow.on('focus', () => { 190 | if (IS_MAC) { 191 | state.unreadNotificationCount = 0; 192 | app.dock.setBadge(''); 193 | } 194 | 195 | if (IS_WINDOWS && trayManager.overlayVisible) { 196 | trayManager.toggleOverlay(false); 197 | } 198 | }); 199 | 200 | ipcMain.on(EVENT_WEBVIEW_NOTIFICATION, (event, msg) => { 201 | if (msg.options) { 202 | const notificationOpts = state.notificationContentHidden ? { 203 | title: 'Android Messages Desktop', 204 | body: 'New Message' 205 | } : { 206 | title: msg.title, 207 | /* 208 | * TODO: Icon is just the logo, which is the only image sent by Google, hopefully someday they will pass 209 | * the sender's picture/avatar here. 210 | * 211 | * We may be able to just do it live by: 212 | * 1. Traversing the DOM for the conversation which matches the sender 213 | * 2. Converting to to SVG to Canvas to PNG using: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Drawing_DOM_objects_into_a_canvas 214 | * 3. Sending image URL which Electron can display via nativeImage.createFromDataURL 215 | * This would likely also require copying computed style properties into the element to ensure it looks right. 216 | * There also appears to be a library: http://html2canvas.hertzen.com 217 | */ 218 | icon: msg.options.icon, 219 | body: msg.options.body, 220 | }; 221 | notificationOpts.silent = !(state.notificationSoundEnabled); 222 | const customNotification = new Notification(notificationOpts); 223 | 224 | if (IS_MAC) { 225 | if (!mainWindow.isFocused()) { 226 | state.unreadNotificationCount += 1; 227 | app.dock.setBadge('' + state.unreadNotificationCount); 228 | } 229 | } 230 | 231 | trayManager.toggleOverlay(true); 232 | 233 | customNotification.once('click', () => { 234 | mainWindow.show(); 235 | }); 236 | 237 | // Allows us to marry our custom notification and its behavior with the helpful behavior 238 | // (conversation highlighting) that Google provides. See the webview bridge for details. 239 | global.currentNotification = customNotification; 240 | event.sender.send(EVENT_NOTIFICATION_REFLECT_READY, true); 241 | 242 | customNotification.show(); 243 | } 244 | }); 245 | 246 | ipcMain.on(EVENT_BRIDGE_INIT, async (event) => { 247 | if (state.bridgeInitDone) { 248 | return; 249 | } 250 | 251 | state.bridgeInitDone = true; 252 | // We have to send un-solicited events (i.e. an event not the result of an event sent to this process) to the webview bridge 253 | // via the renderer process. I'm not sure of a way to get a reference to the androidMessagesWebview inside the renderer from 254 | // here. There may be a legit way to do it, or we can do it a dirty way like how we pass this process to the renderer. 255 | mainWindow.webContents.send(EVENT_UPDATE_USER_SETTING, { 256 | enterToSend: pressEnterToSendEnabled, 257 | useDarkMode: useSystemDarkMode ? nativeTheme.shouldUseDarkColors : null 258 | }); 259 | 260 | let spellCheckFiles = null; 261 | let customWords = null; 262 | const currentLanguage = app.getLocale(); 263 | try { 264 | const supportedLanguages = await DictionaryManager.getSupportedLanguages(); 265 | 266 | const dictionaryLocaleKey = DictionaryManager.doesLanguageExistForLocale(currentLanguage, supportedLanguages); 267 | 268 | if (dictionaryLocaleKey) { // Spellchecking is supported for the current language 269 | spellCheckFiles = await DictionaryManager.getLanguagePath(currentLanguage, dictionaryLocaleKey); 270 | 271 | // We send an event with the language key and array of custom words to the webview bridge which contains the 272 | // instance of the spellchecker. Done this way because passing class instances (i.e. of the spellchecker) 273 | // between electron processes is hacky at best and impossible at worst. 274 | const existingCustomWords = settings.get(SETTING_CUSTOM_WORDS, {}); 275 | 276 | customWords = {}; 277 | if (currentLanguage in existingCustomWords) { 278 | customWords = { [currentLanguage]: existingCustomWords[currentLanguage] }; 279 | } 280 | } 281 | } 282 | catch (error) { 283 | // TODO: Display this as an error message to the user? 284 | } 285 | 286 | event.sender.send(EVENT_SPELLING_REFLECT_READY, { 287 | dictionaryLocaleKey: currentLanguage, 288 | spellCheckFiles, 289 | customWords 290 | }); 291 | }); 292 | 293 | ipcMain.on(EVENT_SPELL_ADD_CUSTOM_WORD, (event, msg) => { 294 | // Add custom words picked by the user to a persistent data store because they must be added to 295 | // the instance of Hunspell on each launch of the app/loading of the dictionary. 296 | const { newCustomWord } = msg; 297 | const currentLanguage = app.getLocale(); 298 | const existingCustomWords = settings.get(SETTING_CUSTOM_WORDS, {}); 299 | if (!(currentLanguage in existingCustomWords)) { 300 | existingCustomWords[currentLanguage] = []; 301 | } 302 | if (newCustomWord && !existingCustomWords[currentLanguage].includes(newCustomWord)) { 303 | existingCustomWords[currentLanguage].push(newCustomWord); 304 | settings.set(SETTING_CUSTOM_WORDS, existingCustomWords); 305 | } 306 | }); 307 | 308 | let quitViaContext = false; 309 | app.on('before-quit', () => { 310 | quitViaContext = true; 311 | }); 312 | 313 | const shouldExitOnMainWindowClosed = () => { 314 | if (IS_MAC) { 315 | return quitViaContext; 316 | } else { 317 | if (trayManager.enabled) { 318 | return quitViaContext; 319 | } 320 | return true; 321 | } 322 | }; 323 | 324 | mainWindow.on('close', (event) => { 325 | console.log('close window called'); 326 | if (!shouldExitOnMainWindowClosed()) { 327 | event.preventDefault(); 328 | mainWindow.hide(); 329 | trayManager.showMinimizeToTrayWarning(); 330 | } else { 331 | app.quit(); // If we don't explicitly call this, the webview and mainWindow get destroyed but background process still runs. 332 | } 333 | }); 334 | 335 | if (IS_DEV) { 336 | mainWindow.openDevTools(); 337 | } 338 | 339 | app.on('web-contents-created', (e, contents) => { 340 | 341 | // Check for a webview 342 | if (contents.getType() == 'webview') { 343 | 344 | // Listen for any new window events 345 | contents.on('new-window', (e, url) => { 346 | e.preventDefault() 347 | shell.openExternal(url) 348 | }); 349 | 350 | contents.on('destroyed', (e) => { 351 | // we will need to re-init on reload 352 | state.bridgeInitDone = false; 353 | }); 354 | 355 | contents.on('will-navigate', (e, url) => { 356 | if (url === 'https://messages.google.com/web/authentication') { 357 | // we were logged out, let's display a notification to the user about this in the future 358 | state.bridgeInitDone = false; 359 | } 360 | }); 361 | } 362 | }); 363 | }); 364 | } 365 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | import env from 'env'; 2 | import path from 'path'; 3 | import { app } from 'electron'; 4 | 5 | const osMap = { 6 | win32: 'Windows', 7 | darwin: 'macOS', 8 | linux: 'Linux' 9 | }; 10 | 11 | // Operating system 12 | const osName = process.platform; 13 | const osNameFriendly = osMap[osName]; 14 | const IS_WINDOWS = (osName === 'win32'); 15 | const IS_MAC = (osName === 'darwin'); 16 | const IS_LINUX = (osName === 'linux'); 17 | 18 | // Environment and paths 19 | const IS_DEV = (env.name === 'development'); 20 | const BASE_APP_PATH = IS_DEV ? path.join(__dirname, '..') : process.resourcesPath; 21 | const RESOURCES_PATH = path.join(BASE_APP_PATH, 'resources'); 22 | const USER_DATA_PATH = () => app.getPath('userData'); // This has to be a function call because app.ready callback must be fired before this path can be used 23 | const SPELLING_DICTIONARIES_PATH = () => path.join(USER_DATA_PATH(), 'dictionaries'); 24 | const SUPPORTED_LANGUAGES_PATH = () => path.join(SPELLING_DICTIONARIES_PATH(), 'supported-languages.json'); 25 | 26 | // Settings 27 | const SETTING_TRAY_ENABLED = 'trayEnabledPref'; 28 | const SETTING_TRAY_CLICK_SHORTCUT = 'trayClickShortcut'; 29 | const SETTING_CUSTOM_WORDS = 'savedCustomDictionaryWords' 30 | 31 | // Events 32 | const EVENT_WEBVIEW_NOTIFICATION = 'messages-webview-notification'; 33 | const EVENT_NOTIFICATION_REFLECT_READY = 'messages-webview-reflect-ready'; 34 | const EVENT_BRIDGE_INIT = 'messages-bridge-init'; 35 | const EVENT_SPELL_ADD_CUSTOM_WORD = 'messages-spelling-add-custom-word'; 36 | const EVENT_SPELLING_REFLECT_READY = 'messages-spelling-reflect-ready'; 37 | const EVENT_UPDATE_USER_SETTING = 'messages-update-user-setting'; 38 | 39 | // Misc. 40 | const DICTIONARY_CACHE_TIME = 2592000000; // 30 days in milliseconds 41 | 42 | export { 43 | osName, 44 | osNameFriendly, 45 | IS_WINDOWS, 46 | IS_MAC, 47 | IS_LINUX, 48 | IS_DEV, 49 | BASE_APP_PATH, 50 | RESOURCES_PATH, 51 | SPELLING_DICTIONARIES_PATH, 52 | SUPPORTED_LANGUAGES_PATH, 53 | SETTING_TRAY_ENABLED, 54 | SETTING_TRAY_CLICK_SHORTCUT, 55 | SETTING_CUSTOM_WORDS, 56 | EVENT_WEBVIEW_NOTIFICATION, 57 | EVENT_NOTIFICATION_REFLECT_READY, 58 | EVENT_BRIDGE_INIT, 59 | EVENT_SPELL_ADD_CUSTOM_WORD, 60 | EVENT_SPELLING_REFLECT_READY, 61 | EVENT_UPDATE_USER_SETTING, 62 | DICTIONARY_CACHE_TIME 63 | }; 64 | -------------------------------------------------------------------------------- /src/helpers/dictionary_manager.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import https from 'https'; 3 | import path from 'path'; 4 | import { SPELLING_DICTIONARIES_PATH, SUPPORTED_LANGUAGES_PATH, DICTIONARY_CACHE_TIME } from '../constants'; 5 | import { maybeGetValidJson, isObject } from './utilities'; 6 | 7 | // Use a known existing commit to dictionaries in case something bad happens to master 8 | const DICTIONARIES_COMMIT_HASH = '2de863c'; 9 | 10 | export default class DictionaryManager { 11 | 12 | static isFileExpired(filePath) { 13 | const fileInfo = fs.statSync(filePath); 14 | const fileModifiedTime = parseInt(fileInfo.mtimeMs, 10); 15 | const nowTime = new Date().getTime(); 16 | const millisecondsSinceFileUpdated = Math.abs(nowTime - fileModifiedTime); 17 | return millisecondsSinceFileUpdated >= DICTIONARY_CACHE_TIME; 18 | } 19 | 20 | static async getSupportedLanguages() { 21 | 22 | return new Promise((resolve, reject) => { 23 | if (!fs.existsSync(SPELLING_DICTIONARIES_PATH())) { 24 | fs.mkdirSync(SPELLING_DICTIONARIES_PATH()); 25 | } 26 | 27 | if (fs.existsSync(SUPPORTED_LANGUAGES_PATH())) { 28 | if (!DictionaryManager.isFileExpired(SUPPORTED_LANGUAGES_PATH())) { 29 | // Supported languages file has not reached max cache time yet (30 days), so try to use it 30 | const jsonStringFromFile = fs.readFileSync(SUPPORTED_LANGUAGES_PATH()); 31 | const supportedLanguagesJsonParsed = maybeGetValidJson(jsonStringFromFile); 32 | if (isObject(supportedLanguagesJsonParsed) && Array.isArray(supportedLanguagesJsonParsed)) { 33 | resolve(supportedLanguagesJsonParsed); 34 | return; 35 | } 36 | } 37 | 38 | // If this point is reached, the file exists but isn't valid JSON, so this function will continue 39 | // (and try to download it again) 40 | } 41 | 42 | // Adapted from: https://stackoverflow.com/questions/35697058/download-and-store-files-inside-electron-app 43 | 44 | const requestOptions = { 45 | host: 'api.github.com', 46 | port: 443, 47 | path: `/repos/wooorm/dictionaries/contents/dictionaries?ref=${DICTIONARIES_COMMIT_HASH}`, 48 | method: 'GET', 49 | headers: { 50 | 'User-Agent': 'chrisknepper/android-messages-desktop' 51 | } 52 | }; 53 | 54 | https.get(requestOptions, (response) => { 55 | if (response.statusCode === 200 || response.statusCode === 302) { 56 | // Only create the local file if it exists on Github 57 | let supportedLanguagesJsonFile = fs.createWriteStream(SUPPORTED_LANGUAGES_PATH()); 58 | response.pipe(supportedLanguagesJsonFile); 59 | 60 | supportedLanguagesJsonFile.on('error', (err) => { 61 | // something went wrong with the download and we may or may not have part of the file 62 | // let's set it to empty since calling unlink is hit or miss for non-root Linux users 63 | if (fs.existsSync((SUPPORTED_LANGUAGES_PATH()))) { 64 | fs.writeFileSync((SUPPORTED_LANGUAGES_PATH()), ''); 65 | } 66 | reject(null); // File write error 67 | return; 68 | }); 69 | supportedLanguagesJsonFile.on('finish', (finished) => { 70 | const jsonStringFromFile = fs.readFileSync(SUPPORTED_LANGUAGES_PATH()); 71 | const supportedLanguagesJsonParsed = maybeGetValidJson(jsonStringFromFile); 72 | if (isObject(supportedLanguagesJsonParsed) && Array.isArray(supportedLanguagesJsonParsed)) { 73 | resolve(supportedLanguagesJsonParsed); 74 | } 75 | }); 76 | } else { 77 | reject(null); 78 | return; 79 | } 80 | }).on('error', (error) => { 81 | reject(null); // Request for JSON failed (likely either Github down or API error) 82 | }); 83 | }); 84 | } 85 | 86 | static doesLanguageExistForLocale(userLanguage, supportedLocales) { 87 | if ((!userLanguage) || (!Array.isArray(supportedLocales))) { 88 | return null; 89 | } 90 | /* 91 | * It is possible for Electron to return a locale code for which there are multiple 92 | * "close match" dictionaries but no exact match. For these special cases, we 93 | * hardcode which dictionary should be used here. 94 | */ 95 | const specialLanguageCases = { 96 | // For a system returning just generic "English", load the Queen's English because its spellings 97 | // are more common anywhere outside of USA, where en-US should always be returned. 98 | en: 'en-GB', 99 | /* 100 | * Electron returns "hy" for any dialect of Armenian but there are only dictionaries for Eastern 101 | * Armenian and Western Armenian--no generic "Armenian." According to Wikipedia, Eastern Armenian 102 | * is more widely spoken and acts as a superset of Western Armenian. Since there is no other 103 | * reliable way to tell which dialect a user would prefer, we use Eastern Armenian because of the 104 | * larger number of speakers of that language. 105 | */ 106 | hy: 'hy-arevela' 107 | }; 108 | 109 | let downloadDictionaryKey = null; 110 | 111 | // Every locale code for which a dictionary exists, as an array 112 | const listOfSupportedLanguages = supportedLocales.map((folder) => { 113 | if (folder.type === 'dir') { 114 | return folder.name 115 | } 116 | }); 117 | 118 | if (listOfSupportedLanguages.includes(userLanguage)) { // language has an exact match and is supported 119 | downloadDictionaryKey = userLanguage; 120 | } else if (userLanguage in specialLanguageCases) { // language is a special case and is supported 121 | downloadDictionaryKey = specialLanguageCases[userLanguage]; 122 | } else { // language may be supported, we'll try to find the closest match available (i.e. another dialect of the same language) 123 | const closestLanguageMatch = listOfSupportedLanguages.filter( 124 | (language) => language.substr(0, 2) === userLanguage.substr(0, 2) 125 | ); 126 | if (closestLanguageMatch.length) { 127 | downloadDictionaryKey = closestLanguageMatch[0]; 128 | } 129 | // else, there are no dictionaries available...womp womp 130 | } 131 | 132 | return downloadDictionaryKey; 133 | } 134 | 135 | static async getLanguagePath(userLanguage, localeKey) { 136 | return new Promise((resolve, reject) => { 137 | const localDictionaryFiles = { 138 | userLanguageAffFile: path.join(SPELLING_DICTIONARIES_PATH(), `${userLanguage}.aff`), 139 | userLanguageDicFile: path.join(SPELLING_DICTIONARIES_PATH(), `${userLanguage}.dic`) 140 | }; 141 | const languageDictFilesExist = fs.existsSync(localDictionaryFiles.userLanguageAffFile) && fs.existsSync(localDictionaryFiles.userLanguageDicFile); 142 | const languageDictFilesTooOld = languageDictFilesExist && DictionaryManager.isFileExpired(localDictionaryFiles.userLanguageAffFile); // Only need to check one of the two 143 | if (languageDictFilesExist && !languageDictFilesTooOld) { 144 | resolve(localDictionaryFiles); 145 | } else { 146 | if (localeKey) { 147 | // Try to download the dictionary files for a language 148 | 149 | const downloadState = { 150 | affFile: false, 151 | dicFile: false 152 | }; 153 | 154 | const dictBaseUrl = `https://raw.githubusercontent.com/wooorm/dictionaries/${DICTIONARIES_COMMIT_HASH}/dictionaries/${localeKey}/index` 155 | 156 | 157 | https.get(`${dictBaseUrl}.aff`, (response) => { 158 | if (response.statusCode === 200 || response.statusCode === 302) { 159 | let affFile = fs.createWriteStream(localDictionaryFiles.userLanguageAffFile); 160 | response.pipe(affFile); 161 | 162 | affFile.on('error', (err) => { 163 | reject(null); // File write error 164 | }); 165 | affFile.on('finish', (finished) => { 166 | downloadState.affFile = true; 167 | 168 | (downloadState.affFile && downloadState.dicFile) && resolve(localDictionaryFiles); 169 | }); 170 | } 171 | }).on('error', (error) => { 172 | reject(null); // File download error (Github down or file doesn't exist) 173 | }); 174 | 175 | https.get(`${dictBaseUrl}.dic`, (response) => { 176 | if (response.statusCode === 200 || response.statusCode === 302) { 177 | let dicFile = fs.createWriteStream(localDictionaryFiles.userLanguageDicFile); 178 | response.pipe(dicFile); 179 | 180 | dicFile.on('error', (err) => { 181 | reject(null); // File write error 182 | }); 183 | dicFile.on('finish', (finished) => { 184 | downloadState.dicFile = true; 185 | 186 | (downloadState.affFile && downloadState.dicFile) && resolve(localDictionaryFiles); 187 | }); 188 | } 189 | }).on('error', (error) => { 190 | reject(null); // File download error (Github down or file doesn't exist) 191 | }); 192 | } 193 | } 194 | }); 195 | } 196 | 197 | } 198 | -------------------------------------------------------------------------------- /src/helpers/tray/tray_manager.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { app, Tray, Menu } from 'electron'; 3 | import { trayMenuTemplate } from '../../menu/tray_menu_template'; 4 | import { IS_MAC, IS_LINUX, IS_WINDOWS, SETTING_TRAY_ENABLED, SETTING_TRAY_CLICK_SHORTCUT } from '../../constants'; 5 | import settings from 'electron-settings'; 6 | 7 | // TODO: Make this static 8 | export default class TrayManager { 9 | constructor() { 10 | // Must declare reference to instance of Tray as a variable, not a const, or bad/weird things happen! 11 | this._tray = null; 12 | // Enable tray/menu bar icon by default except on Linux -- the system having a tray is less of a guarantee on Linux. 13 | this._enabled = settings.get(SETTING_TRAY_ENABLED, (!IS_LINUX)); 14 | this._iconPath = this.setTrayIconPath(); 15 | this._overlayIconPath = this.setOverlayIconPath(); 16 | this._overlayVisible = false; 17 | this._clickShortcut = settings.get(SETTING_TRAY_CLICK_SHORTCUT, 'double-click'); 18 | 19 | this.handleTrayEnabledToggle = this.handleTrayEnabledToggle.bind(this); 20 | this.handleTrayClickShortcutToggle = this.handleTrayClickShortcutToggle.bind(this); 21 | } 22 | 23 | get tray() { 24 | return this._tray; 25 | } 26 | 27 | set tray(trayInstance) { 28 | this._tray = trayInstance; 29 | } 30 | 31 | get enabled() { 32 | return this._enabled; 33 | } 34 | 35 | set enabled(enabled) { 36 | this._enabled = enabled; 37 | } 38 | 39 | get trayIconPath() { 40 | return this._iconPath; 41 | } 42 | 43 | get overlayIconPath() { 44 | return this._overlayIconPath; 45 | } 46 | 47 | get overlayVisible() { 48 | return this._overlayVisible; 49 | } 50 | 51 | set overlayVisible(visible) { 52 | this._overlayVisible = visible; 53 | } 54 | 55 | get clickShortcut() { 56 | return this._clickShortcut; 57 | } 58 | 59 | set clickShortcut(shortcut) { 60 | this._clickShortcut = shortcut; 61 | } 62 | 63 | setTrayIconPath() { 64 | if (IS_WINDOWS) { 65 | // Re-use regular app .ico for the tray icon on Windows. 66 | return path.join(__dirname, '..', 'resources', 'icon.ico'); 67 | } else { 68 | // Mac tray icon filename MUST end in 'Template' and contain only black and transparent pixels. 69 | // Otherwise, automatic inversion and dark mode appearance won't work. 70 | // See: https://stackoverflow.com/questions/41664208/electron-tray-icon-change 71 | const trayIconFileName = IS_MAC ? 'icon_macTemplate.png' : 'icon.png'; 72 | return path.join(__dirname, '..', 'resources', 'tray', trayIconFileName); 73 | } 74 | } 75 | 76 | setOverlayIconPath() { 77 | if (IS_WINDOWS) { 78 | return path.join(__dirname, '..', 'resources', 'tray', 'tray_with_badge.ico'); 79 | } 80 | return null; 81 | } 82 | 83 | startIfEnabled() { 84 | if (this.enabled) { 85 | this.tray = new Tray(this.trayIconPath); 86 | let trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate); 87 | this.tray.setContextMenu(trayContextMenu); 88 | this.setupEventListeners(); 89 | } 90 | } 91 | 92 | setupEventListeners() { 93 | if (IS_WINDOWS) { 94 | this.tray.on(this.clickShortcut, this.handleTrayClick); 95 | } 96 | 97 | // This actually has no effect. Electron docs say that click event is ignored on Linux for 98 | // AppIndicator tray, but I can't find a way to not use AppIndicator for Linux tray. 99 | if (IS_LINUX) { 100 | this.tray.on('click', this.handleTrayClick); 101 | } 102 | } 103 | 104 | destroyEventListeners() { 105 | this.tray.removeListener('click', this.handleTrayClick); 106 | this.tray.removeListener('double-click', this.handleTrayClick); 107 | } 108 | 109 | handleTrayClick(event) { 110 | event.preventDefault(); 111 | if (app.mainWindow) { 112 | app.mainWindow.show(); 113 | } 114 | } 115 | 116 | destroy() { 117 | this.tray.destroy(); 118 | this.tray = null; 119 | } 120 | 121 | showMinimizeToTrayWarning() { 122 | if (IS_WINDOWS && this.enabled) { 123 | const seenMinimizeToTrayWarning = settings.get('seenMinimizeToTrayWarningPref', false); 124 | if (!seenMinimizeToTrayWarning) { 125 | this.tray.displayBalloon({ 126 | title: 'Android Messages', 127 | content: 'Android Messages is still running in the background. To close it, use the File menu or right-click on the tray icon.' 128 | }); 129 | settings.set('seenMinimizeToTrayWarningPref', true); 130 | } 131 | } 132 | } 133 | 134 | handleTrayEnabledToggle(newValue, oldValue) { 135 | this.enabled = newValue; 136 | let liveStartInTrayMenuItemRef = Menu.getApplicationMenu().getMenuItemById('startInTrayMenuItem'); 137 | let livetrayClickShortcutMenuItemRef = Menu.getApplicationMenu().getMenuItemById('trayClickShortcutMenuItem'); 138 | 139 | if (newValue) { 140 | if (!IS_MAC) { 141 | // Must get a live reference to the menu item when updating their properties from outside of them. 142 | liveStartInTrayMenuItemRef.enabled = true; 143 | } 144 | if (IS_WINDOWS) { 145 | livetrayClickShortcutMenuItemRef.enabled = true; 146 | } 147 | if (!this.tray) { 148 | this.startIfEnabled(); 149 | } 150 | } 151 | if (!newValue) { 152 | if (this.tray) { 153 | this.destroy(); 154 | if ((!IS_MAC) && app.mainWindow) { 155 | if (!app.mainWindow.isVisible()) { 156 | app.mainWindow.show(); 157 | } 158 | } 159 | } 160 | if (!IS_MAC) { 161 | // If the app has no tray icon, it can be difficult or impossible to re-gain access to the window, so disallow 162 | // starting hidden, except on Mac, where the app window can still be un-hidden via the dock. 163 | settings.set('startInTrayPref', false); 164 | liveStartInTrayMenuItemRef.enabled = false; 165 | liveStartInTrayMenuItemRef.checked = false; 166 | } 167 | if (IS_WINDOWS) { 168 | livetrayClickShortcutMenuItemRef.enabled = false; 169 | } 170 | if (IS_LINUX) { 171 | // On Linux, the call to tray.destroy doesn't seem to work, causing multiple instances of the tray icon. 172 | // Work around this by quickly restarting the app. 173 | app.relaunch(); 174 | app.exit(0); 175 | } 176 | } 177 | } 178 | 179 | handleTrayClickShortcutToggle(newValue, oldValue) { 180 | this.clickShortcut = newValue; 181 | this.destroyEventListeners(); 182 | this.setupEventListeners(); 183 | } 184 | 185 | toggleOverlay(toggle) { 186 | if (IS_WINDOWS && this.tray && toggle !== this.overlayVisible) { 187 | if (toggle) { 188 | this.tray.setImage(this.overlayIconPath); 189 | } else { 190 | this.tray.setImage(this.trayIconPath); 191 | } 192 | this.overlayVisible = toggle; 193 | } 194 | } 195 | 196 | } 197 | -------------------------------------------------------------------------------- /src/helpers/utilities.js: -------------------------------------------------------------------------------- 1 | function maybeGetValidJson(jsonText) { 2 | if (jsonText === null || jsonText === false || jsonText === '') { 3 | return false; 4 | } 5 | 6 | try { 7 | return JSON.parse(jsonText); 8 | } catch { 9 | return false; 10 | } 11 | } 12 | 13 | function isObject(maybeObj) { 14 | return typeof maybeObj === 'object'; 15 | } 16 | 17 | export { 18 | maybeGetValidJson, 19 | isObject 20 | } 21 | -------------------------------------------------------------------------------- /src/helpers/webview/bridge.js: -------------------------------------------------------------------------------- 1 | // This script is injected into the webview. 2 | 3 | import { popupContextMenu } from './context_menu'; 4 | import { EVENT_WEBVIEW_NOTIFICATION, EVENT_NOTIFICATION_REFLECT_READY, EVENT_BRIDGE_INIT, EVENT_SPELLING_REFLECT_READY, EVENT_UPDATE_USER_SETTING } from '../../constants'; 5 | import { isObject } from '../../helpers/utilities'; 6 | import { ipcRenderer, remote } from 'electron'; 7 | import InputManager from './input_manager'; 8 | import fs from 'fs'; 9 | import { SpellCheckerProvider, attachSpellCheckProvider } from 'electron-hunspell'; 10 | 11 | // Electron (or the build of Chromium it uses?) does not seem to have any default right-click menu, this adds our own. 12 | remote.getCurrentWebContents().addListener('context-menu', popupContextMenu); 13 | 14 | window.onload = () => { 15 | // Conditionally let the main process know the page is (essentially) done loading. 16 | // This should defer spellchecker downloading in a way that avoids blocking the page UI :D 17 | 18 | // Without observing the DOM, we don't have a reliable way to let the main process know once 19 | // (and only once) that the main part of the app (not the QR code screen) has loaded, which is 20 | // when we need to init the spellchecker 21 | const onMutation = function (mutationsList, observer) { 22 | if (document.querySelector('mw-main-nav')) { // we're definitely logged-in if this is in the DOM 23 | ipcRenderer.send(EVENT_BRIDGE_INIT); 24 | observer.disconnect(); 25 | } 26 | // In the future we could detect the "you've been signed in elsewhere" modal and notify the user here 27 | }; 28 | 29 | const observer = new MutationObserver(onMutation); 30 | observer.observe(document.querySelector('body'), { childList: true, attributes: true }); 31 | } 32 | 33 | // The main process, once receiving EVENT_BRIDGE_INIT, determines whether the user's current language allows for spellchecking 34 | // and if so, (down)loads the necessary files, then sends an event to which the following listener responds and 35 | // loads the spellchecker, if needed. 36 | ipcRenderer.once(EVENT_SPELLING_REFLECT_READY, async (event, { dictionaryLocaleKey, spellCheckFiles, customWords }) => { 37 | if (dictionaryLocaleKey && spellCheckFiles && spellCheckFiles.userLanguageAffFile && spellCheckFiles.userLanguageDicFile) { 38 | const provider = new SpellCheckerProvider(); 39 | window.spellCheckHandler = provider; 40 | await provider.initialize({}); // Empty brace correct, see: https://github.com/kwonoj/electron-hunspell/blob/master/example/browserWindow.ts 41 | 42 | await provider.loadDictionary( 43 | dictionaryLocaleKey, 44 | fs.readFileSync(spellCheckFiles.userLanguageDicFile), 45 | fs.readFileSync(spellCheckFiles.userLanguageAffFile) 46 | ); 47 | 48 | const attached = await attachSpellCheckProvider(provider); 49 | attached.switchLanguage(dictionaryLocaleKey); 50 | 51 | let table = window.spellCheckHandler.spellCheckerTable; 52 | if (dictionaryLocaleKey in customWords && table && dictionaryLocaleKey in table) { 53 | for (let i = 0, n = customWords[dictionaryLocaleKey].length; i < n; i++) { 54 | const word = customWords[dictionaryLocaleKey][i]; 55 | table[dictionaryLocaleKey].spellChecker.addWord(word); 56 | } 57 | } 58 | } 59 | }); 60 | 61 | ipcRenderer.on(EVENT_UPDATE_USER_SETTING, (event, settingsList) => { 62 | if (isObject(settingsList)) { 63 | if ('useDarkMode' in settingsList && settingsList.useDarkMode !== null) { 64 | if (settingsList.useDarkMode) { 65 | // Props to Google for making the web app use dark mode entirely based on this class 66 | // and for making the class name semantic! 67 | document.body.classList.add('dark-mode'); 68 | } else { 69 | document.body.classList.remove('dark-mode'); 70 | } 71 | } 72 | if ('enterToSend' in settingsList) { 73 | InputManager.handleEnterPrefToggle(settingsList.enterToSend); 74 | } 75 | } 76 | }); 77 | 78 | const OriginalBrowserNotification = Notification; 79 | 80 | /* 81 | * Override the webview's window's instance of the Notification class and forward their data to the 82 | * main process. This is Necessary to generate and send a custom notification via Electron instead 83 | * of just forwarding the webview (Google) ones. 84 | * 85 | * Derived from: 86 | * https://github.com/electron/electron/blob/master/docs/api/ipc-main.md#sending-messages 87 | * https://stackoverflow.com/questions/2891096/addeventlistener-using-apply 88 | * https://stackoverflow.com/questions/31231622/event-listener-for-web-notification 89 | * https://stackoverflow.com/questions/1421257/intercept-javascript-event 90 | */ 91 | Notification = function (title, options) { 92 | let notificationToSend = new OriginalBrowserNotification(title, options); // Still send the webview notification event so the rest of this code runs (and the ipc event fires) 93 | 94 | /* 95 | * Google's own notifications have a click event listener which takes care of highlighting 96 | * the conversation a notification belongs to, but this click listener does not carry over 97 | * when we block Google's and create our own Electron notification. 98 | * 99 | * What I would like to do here is just pass the listener function over IPC and call it in 100 | * the main process. 101 | * 102 | * However, Electron does not support sending functions or otherwise non-JSON data across IPC. 103 | * To solve this and be able to have both our click event listener (so we can show the app 104 | * window) and Google's (so the converstaion gets selected/highlighted), when the main process 105 | * asyncronously receives the notification data, it asyncronously sends a message back at which 106 | * time we can reliably get a reference to the Electron notification and attach Google's click 107 | * event listener. 108 | */ 109 | let originalClickListener = null; 110 | 111 | const originalAddEventListener = notificationToSend.addEventListener; 112 | notificationToSend.addEventListener = function (type, listener, options) { 113 | if (type === 'click') { 114 | originalClickListener = listener; 115 | } else { 116 | // Let all other event listeners be called, though they shouldn't have any effect 117 | // because the original notification is blocked in the renderer process. 118 | originalAddEventListener.call(notificationToSend, type, listener, options); 119 | } 120 | } 121 | 122 | ipcRenderer.once(EVENT_NOTIFICATION_REFLECT_READY, (event, arg) => { 123 | let theHookedUpNotification = remote.getGlobal('currentNotification'); 124 | if (typeof theHookedUpNotification === 'object' && typeof originalClickListener === 'function') { 125 | theHookedUpNotification.once('click', originalClickListener); 126 | } 127 | }); 128 | 129 | ipcRenderer.send(EVENT_WEBVIEW_NOTIFICATION, { 130 | title, 131 | options 132 | }); 133 | 134 | return notificationToSend; 135 | }; 136 | Notification.prototype = OriginalBrowserNotification.prototype; 137 | Notification.permission = OriginalBrowserNotification.permission; 138 | Notification.requestPermission = OriginalBrowserNotification.requestPermission; 139 | -------------------------------------------------------------------------------- /src/helpers/webview/context_menu.js: -------------------------------------------------------------------------------- 1 | // Provide context menus (copy, paste, save image, etc...) for right-click interaction. 2 | 3 | import { ipcRenderer, remote } from 'electron'; 4 | import { EVENT_SPELL_ADD_CUSTOM_WORD } from '../../constants'; 5 | 6 | const { Menu } = remote; 7 | 8 | const standardMenuTemplate = [ 9 | { 10 | label: 'Copy', 11 | role: 'copy', 12 | }, 13 | { 14 | type: 'separator', 15 | }, 16 | { 17 | label: 'Select All', 18 | role: 'selectall', 19 | } 20 | ]; 21 | 22 | const textMenuTemplate = [ 23 | { 24 | label: 'Undo', 25 | role: 'undo', 26 | }, 27 | { 28 | label: 'Redo', 29 | role: 'redo', 30 | }, 31 | { 32 | type: 'separator', 33 | }, 34 | { 35 | label: 'Cut', 36 | role: 'cut', 37 | }, 38 | { 39 | label: 'Copy', 40 | role: 'copy', 41 | }, 42 | { 43 | label: 'Paste', 44 | role: 'paste', 45 | }, 46 | { 47 | type: 'separator', 48 | }, 49 | { 50 | label: 'Select All', 51 | role: 'selectall', 52 | } 53 | ]; 54 | 55 | const popupContextMenu = async (event, params) => { 56 | // As of Electron 4, Menu.popup no longer accepts being called with the signature popup(remote.getCurrentWindow()) 57 | // It must be passed as an object with the window key. Is this change silly? Yes. Will we know why it was done? No. 58 | const menuPopupArgs = { 59 | window: remote.getCurrentWindow() 60 | }; 61 | 62 | switch (params.mediaType) { 63 | case 'video': 64 | case 'image': 65 | if (params.srcURL && params.srcURL.length) { 66 | let mediaType = params.mediaType[0].toUpperCase() + params.mediaType.slice(1); 67 | const mediaInputMenu = Menu.buildFromTemplate([{ 68 | label: `Save ${mediaType} As...`, 69 | click: () => { 70 | // This call *would* do this in one line, but is only a thing in IE (???) 71 | // document.execCommand('SaveAs', true, params.srcURL); 72 | const link = document.createElement('a'); 73 | link.href = params.srcURL; 74 | /* 75 | * Leaving the URL root results in the file extension being truncated. 76 | * The resulting filename from this also appears to be consistent with 77 | * saving the image via dragging or the Chrome context menu...winning! 78 | * 79 | * Since the URL change from messages.android.com, the URL root of the files 80 | * is messages.google.com (note the lack of /web/ in the path) 81 | */ 82 | link.download = params.srcURL.replace('blob:https://messages.google.com/', ''); 83 | // Trigger save dialog by clicking the "link" 84 | document.body.appendChild(link); 85 | link.click(); 86 | document.body.removeChild(link); 87 | } 88 | }]); 89 | mediaInputMenu.popup({ 90 | window: remote.getCurrentWindow(), 91 | callback: () => { 92 | mediaInputMenu = null; // Unsure if memory would leak without this (Clean up, clean up, everybody do your share) 93 | } 94 | }); 95 | } 96 | break; 97 | default: 98 | if (params.isEditable) { 99 | const textMenuTemplateCopy = [...textMenuTemplate]; 100 | if (window.spellCheckHandler && params.misspelledWord && typeof params.misspelledWord === 'string') { 101 | const booboo = params.selectionText; 102 | textMenuTemplateCopy.unshift({ 103 | type: 'separator' 104 | }); 105 | textMenuTemplateCopy.unshift({ 106 | label: `Add ${booboo} to Dictionary`, 107 | click: async () => { 108 | // Immediately clear red underline 109 | event.sender.replaceMisspelling(booboo); 110 | // Add new custom word to dictionary for the current session 111 | const localeKey = await window.spellCheckHandler.getSelectedDictionaryLanguage(); 112 | window.spellCheckHandler.spellCheckerTable[localeKey].spellChecker.addWord(booboo); 113 | // Send new custom word to main process so it will be added to the dictionary at the start of future sessions 114 | ipcRenderer.send(EVENT_SPELL_ADD_CUSTOM_WORD, { 115 | newCustomWord: booboo 116 | }); 117 | } 118 | }); 119 | 120 | const suggestions = await window.spellCheckHandler.getSuggestion(params.misspelledWord); 121 | if (suggestions && suggestions.length) { 122 | textMenuTemplateCopy.unshift({ 123 | type: 'separator' 124 | }); 125 | 126 | // Hunspell always seems to return the best choices at the end of the array, so reverse it, then limit to 8 suggestions 127 | suggestions.reverse().slice(0, 8).map((correction) => { 128 | let item = { 129 | label: correction, 130 | click: () => { 131 | return event.sender.replaceMisspelling(correction); 132 | } 133 | }; 134 | 135 | textMenuTemplateCopy.unshift(item); 136 | }); 137 | } 138 | } 139 | const textInputMenu = Menu.buildFromTemplate(textMenuTemplateCopy); 140 | textInputMenu.popup(menuPopupArgs); 141 | } else { // Omit options pertaining to input fields if this isn't one 142 | const standardInputMenu = Menu.buildFromTemplate(standardMenuTemplate); 143 | standardInputMenu.popup(menuPopupArgs); 144 | } 145 | } 146 | }; 147 | 148 | export { 149 | popupContextMenu 150 | }; 151 | -------------------------------------------------------------------------------- /src/helpers/webview/input_manager.js: -------------------------------------------------------------------------------- 1 | // Things relating to changing the way user input affect the app page go here 2 | 3 | // We need to block all of these if we're disabling send on enter 4 | const KEYBOARD_EVENTS = ['keyup', 'keypress', 'keydown']; 5 | 6 | // Effectively private methods 7 | 8 | // For whatever reason, this won't work if defined as a static method of InputManager 9 | const blockEnterKeyEvent = (event) => { 10 | if (event.keyCode === 13) { 11 | event.stopPropagation(); 12 | } 13 | } 14 | 15 | export default class InputManager { 16 | 17 | static handleEnterPrefToggle(enabled) { 18 | const addOrRemoveEventListener = (enabled ? window.removeEventListener : window.addEventListener); 19 | 20 | for (let ev of KEYBOARD_EVENTS) { 21 | addOrRemoveEventListener(ev, blockEnterKeyEvent, true); 22 | } 23 | } 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /src/helpers/window.js: -------------------------------------------------------------------------------- 1 | // This helper remembers the size and position of your windows (and restores 2 | // them in that place after app relaunch). 3 | // Can be used for more than one window, just construct many 4 | // instances of it and give each different name. 5 | 6 | import { app, BrowserWindow, screen } from "electron"; 7 | import jetpack from "fs-jetpack"; 8 | 9 | export default (name, options) => { 10 | const userDataDir = jetpack.cwd(app.getPath("userData")); 11 | const stateStoreFile = `window-state-${name}.json`; 12 | const defaultSize = { 13 | width: options.width, 14 | height: options.height 15 | }; 16 | let state = {}; 17 | let win; 18 | 19 | const restore = () => { 20 | let restoredState = {}; 21 | try { 22 | restoredState = userDataDir.read(stateStoreFile, "json"); 23 | } catch (err) { 24 | // For some reason json can't be read (might be corrupted). 25 | // No worries, we have defaults. 26 | } 27 | return Object.assign({}, defaultSize, restoredState); 28 | }; 29 | 30 | const getCurrentPosition = () => { 31 | const position = win.getPosition(); 32 | const size = win.getSize(); 33 | return { 34 | x: position[0], 35 | y: position[1], 36 | width: size[0], 37 | height: size[1] 38 | }; 39 | }; 40 | 41 | const windowWithinBounds = (windowState, bounds) => { 42 | return ( 43 | windowState.x >= bounds.x && 44 | windowState.y >= bounds.y && 45 | windowState.x + windowState.width <= bounds.x + bounds.width && 46 | windowState.y + windowState.height <= bounds.y + bounds.height 47 | ); 48 | }; 49 | 50 | const resetToDefaults = () => { 51 | const bounds = screen.getPrimaryDisplay().bounds; 52 | return Object.assign({}, defaultSize, { 53 | x: (bounds.width - defaultSize.width) / 2, 54 | y: (bounds.height - defaultSize.height) / 2 55 | }); 56 | }; 57 | 58 | const ensureVisibleOnSomeDisplay = windowState => { 59 | const visible = screen.getAllDisplays().some(display => { 60 | return windowWithinBounds(windowState, display.bounds); 61 | }); 62 | if (!visible) { 63 | // Window is partially or fully not visible now. 64 | // Reset it to safe defaults. 65 | return resetToDefaults(); 66 | } 67 | return windowState; 68 | }; 69 | 70 | const saveState = () => { 71 | if (!win.isMinimized() && !win.isMaximized()) { 72 | Object.assign(state, getCurrentPosition()); 73 | } 74 | userDataDir.write(stateStoreFile, state, { atomic: true }); 75 | }; 76 | 77 | state = ensureVisibleOnSomeDisplay(restore()); 78 | 79 | win = new BrowserWindow(Object.assign({}, options, state)); 80 | 81 | win.on("close", saveState); 82 | return win; 83 | }; 84 | -------------------------------------------------------------------------------- /src/menu/app_menu_template.js: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { aboutMenuItem } from './items/about'; 3 | import { checkForUpdatesMenuItem } from './items/check_for_updates'; 4 | import { settingsMenu } from './settings_menu_template'; 5 | 6 | // This is the "Application" menu, which is only used on macOS 7 | export const appMenuTemplate = { 8 | label: 'Android Messages', 9 | submenu: [, 10 | aboutMenuItem, 11 | checkForUpdatesMenuItem, 12 | { 13 | type: 'separator' 14 | }, 15 | settingsMenu, 16 | { 17 | type: 'separator' 18 | }, 19 | { 20 | label: 'Hide Android Messages Desktop', 21 | accelerator: 'Command+H', 22 | click: () => app.hide() 23 | }, 24 | { 25 | type: 'separator', 26 | }, 27 | { 28 | label: 'Quit', 29 | accelerator: 'Command+Q', 30 | click: () => app.quit(), 31 | } 32 | ] 33 | }; 34 | -------------------------------------------------------------------------------- /src/menu/base_menu_template.js: -------------------------------------------------------------------------------- 1 | import { appMenuTemplate } from './app_menu_template'; 2 | import { fileMenuTemplate } from './file_menu_template'; 3 | import { editMenuTemplate } from './edit_menu_template'; 4 | import { settingsMenu } from './settings_menu_template'; 5 | import { viewMenuTemplate } from './view_menu_template'; 6 | import { windowMenuTemplate } from './window_menu_template'; 7 | import { IS_MAC } from '../constants'; 8 | 9 | 10 | const baseMenuTemplate = [editMenuTemplate, viewMenuTemplate, windowMenuTemplate]; 11 | 12 | if (IS_MAC) { 13 | baseMenuTemplate.unshift(appMenuTemplate); 14 | } else { 15 | baseMenuTemplate.unshift(fileMenuTemplate); 16 | baseMenuTemplate.push(settingsMenu); 17 | } 18 | 19 | export { baseMenuTemplate }; 20 | -------------------------------------------------------------------------------- /src/menu/dev_menu_template.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron"; 2 | 3 | export const devMenuTemplate = { 4 | label: "Development", 5 | submenu: [ 6 | { 7 | label: "Reload", 8 | accelerator: "CmdOrCtrl+R", 9 | click: () => { 10 | BrowserWindow.getFocusedWindow().webContents.reloadIgnoringCache(); 11 | } 12 | }, 13 | { 14 | label: "Toggle DevTools", 15 | accelerator: "Alt+CmdOrCtrl+I", 16 | click: () => { 17 | BrowserWindow.getFocusedWindow().toggleDevTools(); 18 | } 19 | }, 20 | { 21 | label: "Quit", 22 | accelerator: "CmdOrCtrl+Q", 23 | click: () => { 24 | app.quit(); 25 | } 26 | } 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /src/menu/edit_menu_template.js: -------------------------------------------------------------------------------- 1 | export const editMenuTemplate = { 2 | label: "Edit", 3 | submenu: [ 4 | { label: "Undo", accelerator: "CmdOrCtrl+Z", selector: "undo:" }, 5 | { label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", selector: "redo:" }, 6 | { type: "separator" }, 7 | { label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:" }, 8 | { label: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:" }, 9 | { label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:" }, 10 | { label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:" } 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /src/menu/file_menu_template.js: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { IS_WINDOWS } from '../constants'; 3 | import { checkForUpdatesMenuItem } from './items/check_for_updates'; 4 | import { separator } from './items/separator'; 5 | 6 | const submenu = [{ 7 | label: 'Quit Android Messages', 8 | click: () => app.quit() 9 | }]; 10 | 11 | if (!IS_WINDOWS) { 12 | submenu.unshift(separator); 13 | submenu.unshift(checkForUpdatesMenuItem); 14 | } 15 | 16 | export const fileMenuTemplate = { 17 | label: 'File', 18 | submenu 19 | }; 20 | -------------------------------------------------------------------------------- /src/menu/help_menu_template.js: -------------------------------------------------------------------------------- 1 | import { shell } from 'electron'; 2 | import { IS_MAC, IS_WINDOWS } from '../constants'; 3 | import { aboutMenuItem } from './items/about'; 4 | import { checkForUpdatesMenuItem } from './items/check_for_updates'; 5 | import { separator } from './items/separator'; 6 | 7 | const submenu = [{ 8 | label: 'Learn More', 9 | click: () => shell.openExternal('https://github.com/chrisknepper/android-messages-desktop/') 10 | }, 11 | { 12 | label: 'Changelog', 13 | click: () => shell.openExternal('https://github.com/chrisknepper/android-messages-desktop/blob/master/CHANGELOG.md') 14 | } 15 | ]; 16 | 17 | if (IS_WINDOWS) { 18 | submenu.push(separator); 19 | submenu.push(checkForUpdatesMenuItem); 20 | } 21 | 22 | if (!IS_MAC) { 23 | submenu.push(separator); 24 | submenu.push(aboutMenuItem); 25 | } 26 | 27 | export const helpMenuTemplate = { 28 | label: 'Help', 29 | submenu 30 | }; 31 | -------------------------------------------------------------------------------- /src/menu/items/about.js: -------------------------------------------------------------------------------- 1 | import appIcon from '../../../resources/icons/512x512.png'; 2 | import { IS_DEV } from '../../constants'; 3 | import openAboutWindow from 'about-window'; 4 | import { app } from 'electron'; 5 | import { description } from '../../../package.json'; 6 | 7 | const productName = 'Android Messages Desktop'; 8 | const localeStyle = '-webkit-app-region: no-drag; position: absolute; left: 0.5em; bottom: 0.5em; font-size: 12px; color: #999'; 9 | const disclaimerText = '

Not affiliated with Google in any way.
Android is a trademark of Google LLC.'; 10 | const licenseText = `

${productName} is released under the MIT License.`; 11 | const dictionaryLicenseText = `

Spelling dictionaries are released under various licenses including MIT, BSD, and GNU GPL. See dictionary license details.` 12 | 13 | let languageCode = ''; 14 | let descriptionWithLocale = ''; 15 | app.on('ready', () => { 16 | languageCode = app.getLocale(); 17 | // about-window does not have a field for arbitrary HTML, so we add the HTML we need to an existing field 18 | descriptionWithLocale = `${description}${languageCode}`; 19 | }); 20 | 21 | export const aboutMenuItem = { 22 | label: `About ${productName}`, 23 | click: () => { 24 | openAboutWindow({ 25 | icon_path: appIcon, 26 | copyright: `
Copyright © 2018-2019 Chris Knepper, All rights reserved.${disclaimerText}${licenseText}${dictionaryLicenseText}
`, 27 | product_name: productName, 28 | description: descriptionWithLocale, 29 | open_devtools: IS_DEV, 30 | use_inner_html: true, 31 | win_options: { 32 | height: 500, 33 | resizable: false, 34 | minimizable: false, 35 | maximizable: false, 36 | show: false // Delays showing until content is ready, prevents FOUC/flash of blank white window 37 | } 38 | }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/menu/items/check_for_updates.js: -------------------------------------------------------------------------------- 1 | import { autoUpdater } from 'electron-updater'; 2 | 3 | export const checkForUpdatesMenuItem = { 4 | label: 'Check for Updates', 5 | click: () => { 6 | autoUpdater.checkForUpdatesAndNotify(); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/menu/items/separator.js: -------------------------------------------------------------------------------- 1 | export const separator = { type: 'separator' }; 2 | -------------------------------------------------------------------------------- /src/menu/settings_menu_template.js: -------------------------------------------------------------------------------- 1 | import { dialog } from 'electron'; 2 | import settings from "electron-settings"; 3 | import { separator } from './items/separator'; 4 | import { IS_LINUX, IS_MAC, IS_WINDOWS, SETTING_TRAY_ENABLED, SETTING_TRAY_CLICK_SHORTCUT } from '../constants'; 5 | 6 | export const settingsMenu = { 7 | label: IS_MAC ? 'Preferences' : 'Settings', 8 | submenu: [ 9 | { 10 | // This option doesn't apply to Mac, so this hides it but keeps the order of menu items 11 | // to make updating based on array indices easier. 12 | visible: (!IS_MAC), 13 | id: 'autoHideMenuBarMenuItem', 14 | label: 'Auto Hide Menu Bar', 15 | type: 'checkbox', 16 | click: (item, window) => { 17 | const autoHideMenuPref = !settings.get('autoHideMenuPref'); 18 | settings.set('autoHideMenuPref', autoHideMenuPref); 19 | item.checked = autoHideMenuPref; 20 | window.setAutoHideMenuBar(autoHideMenuPref); 21 | } 22 | }, 23 | { 24 | id: 'enableTrayIconMenuItem', 25 | label: IS_MAC ? 'Enable Menu Bar Icon' : 'Enable Tray Icon', 26 | type: 'checkbox', 27 | click: (item) => { 28 | const trayEnabledPref = !settings.get(SETTING_TRAY_ENABLED); 29 | let confirmClose = true; 30 | if (IS_LINUX && !trayEnabledPref) { 31 | let dialogAnswer = dialog.showMessageBox({ 32 | type: 'question', 33 | buttons: ['Restart', 'Cancel'], 34 | title: 'App Restart Required', 35 | message: 'Changing this setting requires Android Messages to be restarted.\n\nUnsent text messages may be deleted. Click Restart to apply this setting change and restart Android Messages.' 36 | }); 37 | if (dialogAnswer === 1) { 38 | confirmClose = false; 39 | item.checked = true; // Don't incorrectly flip checkmark if user canceled the dialog 40 | } 41 | } 42 | 43 | if (confirmClose) { 44 | settings.set(SETTING_TRAY_ENABLED, trayEnabledPref); 45 | item.checked = trayEnabledPref; 46 | } 47 | } 48 | }, 49 | { 50 | id: 'startInTrayMenuItem', 51 | label: IS_MAC ? 'Start Hidden' : 'Start In Tray', 52 | type: 'checkbox', 53 | click: (item) => { 54 | const startInTrayPref = !settings.get('startInTrayPref'); 55 | settings.set('startInTrayPref', startInTrayPref); 56 | item.checked = startInTrayPref; 57 | } 58 | } 59 | ] 60 | }; 61 | 62 | // Electron doesn't seem to support the visible property for submenus, so push it instead of hiding it in non-Windows 63 | // See: https://github.com/electron/electron/issues/8703 64 | if (IS_WINDOWS) { 65 | settingsMenu.submenu.push( 66 | { 67 | id: 'trayClickShortcutMenuItem', 68 | label: 'Open from Tray On...', 69 | submenu: [ 70 | { 71 | label: 'Double-click', 72 | type: 'radio', 73 | click: (item) => { 74 | settings.set(SETTING_TRAY_CLICK_SHORTCUT, 'double-click'); 75 | item.checked = true; 76 | } 77 | }, 78 | { 79 | label: 'Single-click', 80 | type: 'radio', 81 | click: (item) => { 82 | settings.set(SETTING_TRAY_CLICK_SHORTCUT, 'click'); 83 | item.checked = true; 84 | } 85 | } 86 | ] 87 | } 88 | ); 89 | } 90 | 91 | settingsMenu.submenu.push( 92 | separator, 93 | { 94 | id: 'notificationSoundEnabledMenuItem', 95 | label: 'Play Notification Sound', 96 | type: 'checkbox', 97 | click: (item) => { 98 | settings.set('notificationSoundEnabledPref', item.checked); 99 | } 100 | }, 101 | separator, 102 | { 103 | id: 'pressEnterToSendMenuItem', 104 | label: 'Press Enter to Send Message', 105 | type: 'checkbox', 106 | click: (item) => { 107 | settings.set('pressEnterToSendPref', item.checked); 108 | } 109 | }, 110 | separator, 111 | { 112 | id: 'hideNotificationContentMenuItem', 113 | label: 'Hide Notification Content', 114 | type: 'checkbox', 115 | click: (item) => { 116 | settings.set('hideNotificationContentPref', item.checked); 117 | } 118 | }, 119 | separator, 120 | { 121 | id: 'useSystemDarkModeMenuItem', 122 | label: 'Use System Dark Mode Setting', 123 | type: 'checkbox', 124 | click: (item) => { 125 | settings.set('useSystemDarkModePref', item.checked); 126 | } 127 | } 128 | ); 129 | -------------------------------------------------------------------------------- /src/menu/tray_menu_template.js: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import { IS_MAC } from '../constants'; 3 | 4 | export const trayMenuTemplate = [ 5 | { 6 | label: 'Show/Hide Android Messages', 7 | click: () => { 8 | if (app.mainWindow) { 9 | if (app.mainWindow.isVisible()) { 10 | if (IS_MAC) { 11 | app.hide(); 12 | } else { 13 | app.mainWindow.hide(); 14 | } 15 | } else { 16 | app.mainWindow.show(); 17 | } 18 | } 19 | } 20 | }, 21 | { 22 | type: 'separator' 23 | }, 24 | { 25 | label: 'Quit Android Messages', 26 | click: () => { 27 | app.quit(); 28 | } 29 | } 30 | ]; 31 | -------------------------------------------------------------------------------- /src/menu/view_menu_template.js: -------------------------------------------------------------------------------- 1 | export const viewMenuTemplate = { 2 | label: "View", 3 | submenu: [ 4 | { 5 | role: "toggleFullScreen", 6 | }, 7 | { 8 | role: "reload" 9 | }, 10 | { 11 | type: 'separator' 12 | }, 13 | { 14 | role: "resetZoom" 15 | }, 16 | // Having two items to get the zoom-in functionality is necessary due to a bug in Electron 17 | // Without doing this, either the keyboard shortcut is displayed wrong, or zooming in doesn't work 18 | // See: https://github.com/electron/electron/issues/15496 19 | { 20 | role: "zoomIn" 21 | }, 22 | { 23 | role: 'zoomin', 24 | accelerator: 'CommandOrControl+=', 25 | visible: false, 26 | enabled: true, 27 | }, 28 | { 29 | role: "zoomOut" 30 | }, 31 | ] 32 | }; 33 | -------------------------------------------------------------------------------- /src/menu/window_menu_template.js: -------------------------------------------------------------------------------- 1 | export const windowMenuTemplate = { 2 | label: 'Window', 3 | role: 'windowMenu' 4 | }; 5 | -------------------------------------------------------------------------------- /src/stylesheets/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 100%; 4 | height: 100%; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | body { 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | font-family: sans-serif; 14 | color: #525252; 15 | } 16 | 17 | a { 18 | text-decoration: none; 19 | color: #cb3837; 20 | } 21 | 22 | #app { 23 | width: 100vw; 24 | height: 100vh; 25 | text-align: center; 26 | } 27 | 28 | #androidMessagesWebview { 29 | width: 100%; 30 | height: 100%; 31 | } 32 | 33 | #loader { 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | right: 0; 38 | bottom: 0; 39 | width: 100vw; 40 | height: 100vh; 41 | background-color: #335ec9; 42 | opacity: 1; 43 | transition: opacity 0.4s 0.4s ease-in-out; 44 | } 45 | 46 | #loader.hidden { 47 | opacity: 0; 48 | pointer-events: none; 49 | } 50 | 51 | #titlebar { 52 | -webkit-app-region: drag; 53 | position: fixed; 54 | width: 100%; 55 | height: 64px; 56 | top: 0; 57 | left: 0; 58 | background: none; 59 | pointer-events: none; 60 | } 61 | --------------------------------------------------------------------------------