",
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 |
--------------------------------------------------------------------------------