Compare commits

..

2 Commits

Author SHA1 Message Date
Akash Nimare
be869a52d5 release: v4.0.3. 2020-02-28 10:14:22 +05:30
Akash Nimare
fcbb5da18e webview: Update web security preference.
Electron docs suggests that we should not use
`disablewebsecurity` thus removing the same.
2020-02-28 10:13:30 +05:30
104 changed files with 11667 additions and 10635 deletions

2
.gitattributes vendored
View File

@@ -1,5 +1,7 @@
* text=auto eol=lf
package-lock.json binary
app/package-lock.json binary
*.gif binary
*.jpg binary
*.jpeg binary

4
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Dependency directory
/node_modules/
# Dependency directories
node_modules/
# npm cache directory
.npm

View File

@@ -1,3 +1,6 @@
sudo: required
dist: trusty
os:
- osx
- linux
@@ -12,12 +15,23 @@ addons:
language: node_js
node_js:
- '10'
- '12'
- '8'
before_install:
- ./scripts/travis-xvfb.sh
- npm install -g gulp
- npm install
cache:
directories:
- node_modules
- app/node_modules
script:
- npm run test
- npm run travis
notifications:
webhooks:
urls:
- https://zulip.org/zulipbot/travis
on_success: always
on_failure: always

View File

@@ -1,11 +1,11 @@
import {app, dialog} from 'electron';
import {UpdateDownloadedEvent, UpdateInfo, autoUpdater} from 'electron-updater';
import {linuxUpdateNotification} from './linuxupdater'; // Required only in case of linux
'use strict';
import { app, dialog, shell } from 'electron';
import { autoUpdater } from 'electron-updater';
import { linuxUpdateNotification } from './linuxupdater'; // Required only in case of linux
import log from 'electron-log';
import isDev from 'electron-is-dev';
import * as ConfigUtil from '../renderer/js/utils/config-util';
import * as LinkUtil from '../renderer/js/utils/link-util';
import log = require('electron-log');
import isDev = require('electron-is-dev');
import ConfigUtil = require('../renderer/js/utils/config-util');
export function appUpdater(updateFromMenu = false): void {
// Don't initiate auto-updates in development
@@ -34,73 +34,73 @@ export function appUpdater(updateFromMenu = false): void {
autoUpdater.allowPrerelease = isBetaUpdate || false;
const eventsListenerRemove = ['update-available', 'update-not-available'];
autoUpdater.on('update-available', async (info: UpdateInfo) => {
autoUpdater.on('update-available', info => {
if (updateFromMenu) {
dialog.showMessageBox({
message: `A new version ${info.version}, of Zulip Desktop is available`,
detail: 'The update will be downloaded in the background. You will be notified when it is ready to be installed.'
});
updateAvailable = true;
// This is to prevent removal of 'update-downloaded' and 'error' event listener.
eventsListenerRemove.forEach(event => {
autoUpdater.removeAllListeners(event);
});
await dialog.showMessageBox({
message: `A new version ${info.version}, of Zulip Desktop is available`,
detail: 'The update will be downloaded in the background. You will be notified when it is ready to be installed.'
});
}
});
autoUpdater.on('update-not-available', async () => {
autoUpdater.on('update-not-available', () => {
if (updateFromMenu) {
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these
// listeners don't trigger multiple times.
autoUpdater.removeAllListeners();
await dialog.showMessageBox({
dialog.showMessageBox({
message: 'No updates available',
detail: `You are running the latest version of Zulip Desktop.\nVersion: ${app.getVersion()}`
});
}
});
autoUpdater.on('error', async (error: Error) => {
if (updateFromMenu) {
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these
// listeners don't trigger multiple times.
autoUpdater.removeAllListeners();
}
});
autoUpdater.on('error', error => {
if (updateFromMenu) {
const messageText = (updateAvailable) ? ('Unable to download the updates') : ('Unable to check for updates');
const {response} = await dialog.showMessageBox({
dialog.showMessageBox({
type: 'error',
buttons: ['Manual Download', 'Cancel'],
message: messageText,
detail: `Error: ${error.message}\n\nThe latest version of Zulip Desktop is available at -\nhttps://zulipchat.com/apps/.\n
Current Version: ${app.getVersion()}`
detail: (error).toString() + `\n\nThe latest version of Zulip Desktop is available at -\nhttps://zulipchat.com/apps/.\n
Current Version: ${app.getVersion()}`
}, response => {
if (response === 0) {
shell.openExternal('https://zulipchat.com/apps/');
}
});
if (response === 0) {
await LinkUtil.openBrowser(new URL('https://zulipchat.com/apps/'));
}
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these
// listeners don't trigger multiple times.
autoUpdater.removeAllListeners();
}
});
// Ask the user if update is available
autoUpdater.on('update-downloaded', async (event: UpdateDownloadedEvent) => {
autoUpdater.on('update-downloaded', event => {
// Ask user to update the app
const {response} = await dialog.showMessageBox({
dialog.showMessageBox({
type: 'question',
buttons: ['Install and Relaunch', 'Install Later'],
defaultId: 0,
message: `A new update ${event.version} has been downloaded`,
detail: 'It will be installed the next time you restart the application'
}, response => {
if (response === 0) {
setTimeout(() => {
autoUpdater.quitAndInstall();
// force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app.
app.quit();
}, 1000);
}
});
if (response === 0) {
setTimeout(() => {
autoUpdater.quitAndInstall();
// Force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app.
app.quit();
}, 1000);
}
});
// Init for updates
(async () => autoUpdater.checkForUpdates())();
autoUpdater.checkForUpdates();
}

View File

@@ -1,17 +1,19 @@
import {sentryInit} from '../renderer/js/utils/sentry-util';
import {appUpdater} from './autoupdater';
import {setAutoLaunch} from './startup';
'use strict';
import { sentryInit } from '../renderer/js/utils/sentry-util';
import { appUpdater } from './autoupdater';
import { setAutoLaunch } from './startup';
import windowStateKeeper from 'electron-window-state';
import path from 'path';
import fs from 'fs';
import electron, {app, dialog, ipcMain, session} from 'electron';
import windowStateKeeper = require('electron-window-state');
import path = require('path');
import fs = require('fs');
import isDev = require('electron-is-dev');
import electron = require('electron');
const { app, ipcMain, session } = electron;
import * as AppMenu from './menu';
import * as BadgeSettings from '../renderer/js/pages/preference/badge-settings';
import * as CertificateUtil from '../renderer/js/utils/certificate-util';
import * as ConfigUtil from '../renderer/js/utils/config-util';
import * as ProxyUtil from '../renderer/js/utils/proxy-util';
import AppMenu = require('./menu');
import BadgeSettings = require('../renderer/js/pages/preference/badge-settings');
import ConfigUtil = require('../renderer/js/utils/config-util');
import ProxyUtil = require('../renderer/js/utils/proxy-util');
interface PatchedGlobal extends NodeJS.Global {
mainWindowState: windowStateKeeper.State;
@@ -19,6 +21,12 @@ interface PatchedGlobal extends NodeJS.Global {
const globalPatched = global as PatchedGlobal;
// Adds debug features like hotkeys for triggering dev tools and reload
// in development mode
if (isDev) {
require('electron-debug')();
}
// Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow;
let badgeCount: number;
@@ -43,34 +51,12 @@ if (singleInstanceLock) {
app.quit();
}
const rendererCallbacks = new Map();
let nextRendererCallbackId = 0;
ipcMain.on('renderer-callback', (event: Event, rendererCallbackId: number, ...args: any[]) => {
rendererCallbacks.get(rendererCallbackId)(...args);
rendererCallbacks.delete(rendererCallbackId);
});
function makeRendererCallback(callback: (...args: any[]) => void): number {
rendererCallbacks.set(nextRendererCallbackId, callback);
return nextRendererCallbackId++;
}
const APP_ICON = path.join(__dirname, '../resources', 'Icon');
const iconPath = (): string => {
return APP_ICON + (process.platform === 'win32' ? '.ico' : '.png');
};
// Toggle the app window
const toggleApp = (): void => {
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
};
function createMainWindow(): Electron.BrowserWindow {
// Load the previous state with fallback to defaults
const mainWindowState: windowStateKeeper.State = windowStateKeeper({
@@ -95,8 +81,7 @@ function createMainWindow(): Electron.BrowserWindow {
webPreferences: {
plugins: true,
nodeIntegration: true,
partition: 'persist:webviewsession',
webviewTag: true
partition: 'persist:webviewsession'
},
show: false
});
@@ -105,16 +90,15 @@ function createMainWindow(): Electron.BrowserWindow {
win.webContents.send('focus');
});
(async () => win.loadURL(mainURL))();
win.loadURL(mainURL);
// Keep the app running in background on close event
win.on('close', event => {
if (ConfigUtil.getConfigItem('quitOnClose')) {
win.on('close', e => {
if (ConfigUtil.getConfigItem("quitOnClose")) {
app.quit();
}
if (!isQuitting) {
event.preventDefault();
e.preventDefault();
if (process.platform === 'darwin') {
app.hide();
@@ -135,8 +119,8 @@ function createMainWindow(): Electron.BrowserWindow {
});
// To destroy tray icon when navigate to a new URL
win.webContents.on('will-navigate', event => {
if (event) {
win.webContents.on('will-navigate', e => {
if (e) {
win.webContents.send('destroytray');
}
});
@@ -156,20 +140,19 @@ app.disableHardwareAcceleration();
// More info here - https://github.com/electron/electron/issues/10732
app.commandLine.appendSwitch('force-color-profile', 'srgb');
// This event is only available on macOS. Triggers when you click on the dock icon.
// eslint-disable-next-line max-params
app.on('certificate-error', (event: Event, _webContents: Electron.WebContents, _url: string, _error: string, _certificate: any, callback) => {
event.preventDefault();
callback(true);
});
app.on('activate', () => {
if (mainWindow) {
// If there is already a window show it
mainWindow.show();
} else {
if (!mainWindow) {
mainWindow = createMainWindow();
}
});
app.on('ready', () => {
const ses = session.fromPartition('persist:webviewsession');
ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`);
AppMenu.setMenu({
tabs: []
});
@@ -178,7 +161,7 @@ app.on('ready', () => {
// Auto-hide menu bar on Windows + Linux
if (process.platform !== 'darwin') {
const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false;
mainWindow.autoHideMenuBar = shouldHideMenu;
mainWindow.setAutoHideMenuBar(shouldHideMenu);
mainWindow.setMenuBarVisibility(!shouldHideMenu);
}
@@ -191,7 +174,7 @@ app.on('ready', () => {
const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (isSystemProxy) {
(async () => ProxyUtil.resolveSystemProxy(mainWindow))();
ProxyUtil.resolveSystemProxy(mainWindow);
}
const page = mainWindow.webContents;
@@ -202,10 +185,10 @@ app.on('ready', () => {
} else {
mainWindow.show();
}
});
ipcMain.on('fetch-user-agent', event => {
event.returnValue = session.fromPartition('persist:webviewsession').getUserAgent();
if (!ConfigUtil.isConfigItemExists('userAgent')) {
const userAgent = session.fromPartition('webview:persistsession').getUserAgent();
ConfigUtil.setConfigItem('userAgent', userAgent);
}
});
page.once('did-frame-finish-load', () => {
@@ -215,64 +198,6 @@ app.on('ready', () => {
}
});
app.on('certificate-error', (
event: Event,
webContents: Electron.WebContents,
url: string,
error: string,
certificate: Electron.Certificate,
callback: (isTrusted: boolean) => void
) /* eslint-disable-line max-params */ => {
// TODO: The entire concept of selectively ignoring certificate errors
// is ill-conceived, and this handler needs to be deleted.
event.preventDefault();
const {origin} = new URL(url);
const filename = CertificateUtil.getCertificate(encodeURIComponent(origin));
if (filename !== undefined) {
try {
const savedCertificate = fs.readFileSync(
path.join(`${app.getPath('userData')}/certificates`, filename),
'utf8'
);
if (certificate.data.replace(/[\r\n]/g, '') ===
savedCertificate.replace(/[\r\n]/g, '')) {
callback(true);
return;
}
} catch (error) {
console.error(`Error reading certificate file ${filename}:`, error);
}
}
page.send(
'certificate-error',
webContents.id === mainWindow.webContents.id ? null : webContents.id,
makeRendererCallback(ignore => {
callback(ignore);
if (!ignore) {
dialog.showErrorBox(
'Certificate error',
`The server presented an invalid certificate for ${origin}:
${error}`
);
}
})
);
});
page.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
const {origin} = new URL(details.requestingUrl);
page.send('permission-request', {
webContentsId: webContents.id === mainWindow.webContents.id ?
null :
webContents.id,
origin,
permission
}, makeRendererCallback(callback));
});
// Temporarily remove this event
// electron.powerMonitor.on('resume', () => {
// mainWindow.reload();
@@ -326,34 +251,38 @@ ${error}`
});
ipcMain.on('toggle-app', () => {
toggleApp();
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
});
ipcMain.on('toggle-badge-option', () => {
BadgeSettings.updateBadge(badgeCount, mainWindow);
});
ipcMain.on('toggle-menubar', (_event: Electron.IpcMainEvent, showMenubar: boolean) => {
mainWindow.autoHideMenuBar = showMenubar;
ipcMain.on('toggle-menubar', (_event: Electron.IpcMessageEvent, showMenubar: boolean) => {
mainWindow.setAutoHideMenuBar(showMenubar);
mainWindow.setMenuBarVisibility(!showMenubar);
page.send('toggle-autohide-menubar', showMenubar, true);
});
ipcMain.on('update-badge', (_event: Electron.IpcMainEvent, messageCount: number) => {
ipcMain.on('update-badge', (_event: Electron.IpcMessageEvent, messageCount: number) => {
badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow);
page.send('tray', messageCount);
});
ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMainEvent, data: string, text: string) => {
ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMessageEvent, data: any, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
});
ipcMain.on('forward-message', (_event: Electron.IpcMainEvent, listener: string, ...parameters: unknown[]) => {
page.send(listener, ...parameters);
ipcMain.on('forward-message', (_event: Electron.IpcMessageEvent, listener: any, ...params: any[]) => {
page.send(listener, ...params);
});
ipcMain.on('update-menu', (_event: Electron.IpcMainEvent, props: AppMenu.MenuProps) => {
ipcMain.on('update-menu', (_event: Electron.IpcMessageEvent, props: any) => {
AppMenu.setMenu(props);
const activeTab = props.tabs[props.activeTabIndex];
if (activeTab) {
@@ -361,39 +290,35 @@ ${error}`
}
});
ipcMain.on('toggleAutoLauncher', async (_event: Electron.IpcMainEvent, AutoLaunchValue: boolean) => {
await setAutoLaunch(AutoLaunchValue);
ipcMain.on('toggleAutoLauncher', (_event: Electron.IpcMessageEvent, AutoLaunchValue: boolean) => {
setAutoLaunch(AutoLaunchValue);
});
ipcMain.on('downloadFile', (_event: Electron.IpcMainEvent, url: string, downloadPath: string) => {
ipcMain.on('downloadFile', (_event: Electron.IpcMessageEvent, url: string, downloadPath: string) => {
page.downloadURL(url);
page.session.once('will-download', async (_event: Event, item) => {
if (ConfigUtil.getConfigItem('promptDownload', false)) {
const showDialogOptions: object = {
defaultPath: path.join(downloadPath, item.getFilename())
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date();
return date.getTime();
};
page.session.once('will-download', (_event: Event, item) => {
const filePath = path.join(downloadPath, item.getFilename());
const formatFile = (filePath: string): string => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
const getTimeStamp = (): any => {
const date = new Date();
return date.getTime();
};
const filePath = path.join(downloadPath, item.getFilename());
const formatFile = (filePath: string): string => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
// Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath: string = fs.existsSync(filePath) ? updatedFilePath : filePath;
item.setSavePath(setFilePath);
}
// Update the name and path of the file if it already exists
const updatedListener = (_event: Event, state: string): void => {
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath = fs.existsSync(filePath) ? updatedFilePath : filePath;
item.setSavePath(setFilePath);
item.on('updated', (_event: Event, state) => {
switch (state) {
case 'interrupted': {
// Can interrupted to due to network error, cancel download then
@@ -401,52 +326,47 @@ ${error}`
item.cancel();
break;
}
case 'progressing': {
if (item.isPaused()) {
item.cancel();
}
// This event can also be used to show progress in percentage in future.
break;
}
default: {
console.info('Unknown updated state of download item');
}
}
};
item.on('updated', updatedListener);
});
item.once('done', (_event: Event, state) => {
const getFileName = fs.existsSync(filePath) ? formatFile(filePath) : item.getFilename();
if (state === 'completed') {
page.send('downloadFileCompleted', item.getSavePath(), path.basename(item.getSavePath()));
page.send('downloadFileCompleted', item.getSavePath(), getFileName);
} else {
console.log('Download failed state:', state);
page.send('downloadFileFailed', state);
console.log('Download failed state: ', state);
page.send('downloadFileFailed');
}
// To stop item for listening to updated events of this file
item.removeListener('updated', updatedListener);
item.removeAllListeners('updated');
});
});
});
ipcMain.on('realm-name-changed', (_event: Electron.IpcMainEvent, serverURL: string, realmName: string) => {
ipcMain.on('realm-name-changed', (_event: Electron.IpcMessageEvent, serverURL: string, realmName: string) => {
page.send('update-realm-name', serverURL, realmName);
});
ipcMain.on('realm-icon-changed', (_event: Electron.IpcMainEvent, serverURL: string, iconURL: string) => {
ipcMain.on('realm-icon-changed', (_event: Electron.IpcMessageEvent, serverURL: string, iconURL: string) => {
page.send('update-realm-icon', serverURL, iconURL);
});
// Using event.sender.send instead of page.send here to
// make sure the value of errorReporting is sent only once on load.
ipcMain.on('error-reporting', (event: Electron.IpcMainEvent) => {
ipcMain.on('error-reporting', (event: Electron.IpcMessageEvent) => {
event.sender.send('error-reporting-val', errorReporting);
});
ipcMain.on('save-last-tab', (_event: Electron.IpcMainEvent, index: number) => {
ipcMain.on('save-last-tab', (_event: Electron.IpcMessageEvent, index: number) => {
ConfigUtil.setConfigItem('lastActiveTab', index);
});
@@ -455,12 +375,19 @@ ${error}`
setInterval(() => {
// Set user idle if no activity in 1 second (idleThresholdSeconds)
const idleThresholdSeconds = 1; // 1 second
const idleState = electron.powerMonitor.getSystemIdleState(idleThresholdSeconds);
if (idleState === 'active') {
page.send('set-active');
} else {
page.send('set-idle');
}
// TODO: Remove typecast to any when types get added
// TODO: use powerMonitor.getSystemIdleState when upgrading electron
// powerMonitor.querySystemIdleState is deprecated in current electron
// version at the time of writing.
const powerMonitor = electron.powerMonitor as any;
powerMonitor.querySystemIdleState(idleThresholdSeconds, (idleState: string) => {
if (idleState === 'active') {
page.send('set-active');
} else {
page.send('set-idle');
}
});
}, idleCheckInterval);
});

View File

@@ -1,11 +1,11 @@
import {app, Notification} from 'electron';
import { app, Notification } from 'electron';
import request from 'request';
import semver from 'semver';
import * as ConfigUtil from '../renderer/js/utils/config-util';
import * as ProxyUtil from '../renderer/js/utils/proxy-util';
import * as LinuxUpdateUtil from '../renderer/js/utils/linux-update-util';
import Logger from '../renderer/js/utils/logger-util';
import request = require('request');
import semver = require('semver');
import ConfigUtil = require('../renderer/js/utils/config-util');
import ProxyUtil = require('../renderer/js/utils/proxy-util');
import LinuxUpdateUtil = require('../renderer/js/utils/linux-update-util');
import Logger = require('../renderer/js/utils/logger-util');
const logger = new Logger({
file: 'linux-update-util.log',
@@ -24,24 +24,20 @@ export function linuxUpdateNotification(): void {
ecdhCurve: 'auto'
};
request(options, (error, response: request.Response, body: string) => {
request(options, (error: any, response: any, body: any) => {
if (error) {
logger.error('Linux update error.');
logger.error(error);
return;
}
if (response.statusCode < 400) {
const data = JSON.parse(body);
const latestVersion = ConfigUtil.getConfigItem('betaUpdate') ? data[0].tag_name : data.tag_name;
if (typeof latestVersion !== 'string') {
throw new TypeError('Expected string for tag_name');
}
if (semver.gt(latestVersion, app.getVersion())) {
const notified = LinuxUpdateUtil.getUpdateItem(latestVersion);
if (notified === null) {
new Notification({title: 'Zulip Update', body: `A new version ${latestVersion} is available. Please update using your package manager.`}).show();
new Notification({title: 'Zulip Update', body: 'A new version ' + latestVersion + ' is available. Please update using your package manager.'}).show();
LinuxUpdateUtil.setUpdateItem(latestVersion, true);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,31 @@
import {app} from 'electron';
'use strict';
import { app } from 'electron';
import AutoLaunch from 'auto-launch';
import isDev from 'electron-is-dev';
import * as ConfigUtil from '../renderer/js/utils/config-util';
import AutoLaunch = require('auto-launch');
import isDev = require('electron-is-dev');
import ConfigUtil = require('../renderer/js/utils/config-util');
export const setAutoLaunch = async (AutoLaunchValue: boolean): Promise<void> => {
export const setAutoLaunch = (AutoLaunchValue: boolean): void => {
// Don't run this in development
if (isDev) {
return;
}
// On Mac, work around a bug in auto-launch where it opens a Terminal window
// See https://github.com/Teamwork/node-auto-launch/issues/28#issuecomment-222194437
const appPath = process.platform === 'darwin' ? app.getPath('exe').replace(/\.app\/Content.*/, '.app') : undefined; // Use the default
const ZulipAutoLauncher = new AutoLaunch({
name: 'Zulip',
path: appPath,
isHidden: false
});
const autoLaunchOption = ConfigUtil.getConfigItem('startAtLogin', AutoLaunchValue);
// `setLoginItemSettings` doesn't support linux
if (process.platform === 'linux') {
const ZulipAutoLauncher = new AutoLaunch({
name: 'Zulip',
isHidden: false
});
if (autoLaunchOption) {
await ZulipAutoLauncher.enable();
} else {
await ZulipAutoLauncher.disable();
}
if (autoLaunchOption) {
ZulipAutoLauncher.enable();
} else {
app.setLoginItemSettings({
openAtLogin: autoLaunchOption,
openAsHidden: false
});
ZulipAutoLauncher.disable();
}
};

1940
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
app/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "4.0.3",
"desktopName": "zulip.desktop",
"description": "Zulip Desktop App",
"license": "Apache-2.0",
"copyright": "Kandra Labs, Inc.",
"author": {
"name": "Kandra Labs, Inc.",
"email": "support@zulipchat.com"
},
"repository": {
"type": "git",
"url": "https://github.com/zulip/zulip-desktop.git"
},
"bugs": {
"url": "https://github.com/zulip/zulip-desktop/issues"
},
"main": "main/index.js",
"keywords": [
"Zulip",
"Group Chat app",
"electron-app",
"electron",
"Desktop app",
"InstantMessaging"
],
"dependencies": {
"@electron-elements/send-feedback": "1.0.8",
"@sentry/electron": "0.14.0",
"adm-zip": "0.4.11",
"auto-launch": "5.0.5",
"backoff": "2.5.0",
"dotenv": "8.0.0",
"electron-is-dev": "0.3.0",
"electron-log": "2.2.14",
"electron-spellchecker": "1.1.2",
"electron-updater": "4.0.6",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"i18n": "0.8.3",
"node-json-db": "0.9.2",
"request": "2.85.0",
"semver": "5.4.1",
"wurl": "2.5.0"
},
"optionalDependencies": {
"node-mac-notifier": "1.1.0"
}
}

View File

@@ -14,19 +14,33 @@
<div class="maintenance-info">
<p class="detail maintainer">
Maintained by
<a href="https://zulipchat.com" target="_blank" rel="noopener noreferrer">Zulip</a>
<a onclick="linkInBrowser('website')">Zulip</a>
</p>
<p class="detail license">
Available under the
<a href="https://github.com/zulip/zulip-desktop/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">Apache 2.0 License</a>
<a onclick="linkInBrowser('license')">Apache 2.0 License</a>
</p>
</div>
</div>
<script>
const { app } = require('electron').remote;
const { shell } = require('electron');
const version_tag = document.querySelector('#version');
version_tag.innerHTML = 'v' + app.getVersion();
function linkInBrowser(type) {
let url;
switch (type) {
case 'website':
url = "https://zulipchat.com";
break;
case 'license':
url = "https://github.com/zulip/zulip-desktop/blob/master/LICENSE";
break;
}
shell.openExternal(url);
}
</script>
<script>require('./js/shared/preventdrag.js')</script>
</body>

View File

@@ -724,13 +724,3 @@ i.open-network-button {
margin-top: 10px;
}
}
.lang-menu {
font-size: 13px;
font-weight: bold;
background: rgba(78, 191, 172, 1.000);
width: 100px;
height: 38px;
color: rgba(255, 255, 255, 1.000);
border-color: rgba(0, 0, 0, 0);
}

View File

@@ -1,82 +0,0 @@
import {clipboard} from 'electron';
import crypto from 'crypto';
// This helper is exposed via electron_bridge for use in the social
// login flow.
//
// It consists of a key and a promised token. The in-app page sends
// the key to the server, and opens the users browser to a page where
// they can log in and get a token encrypted to that key. When the
// user copies the encrypted token from their browser to the
// clipboard, we decrypt it and resolve the promise. The in-app page
// then uses the decrypted token to log the user in within the app.
//
// The encryption is authenticated (AES-GCM) to guarantee that we
// dont leak anything from the users clipboard other than the token
// intended for us.
export class ClipboardDecrypterImpl implements ClipboardDecrypter {
version: number;
key: Uint8Array;
pasted: Promise<string>;
constructor(_: number) {
// At this time, the only version is 1.
this.version = 1;
this.key = crypto.randomBytes(32);
this.pasted = new Promise(resolve => {
let interval: NodeJS.Timeout | null = null;
const startPolling = () => {
if (interval === null) {
interval = setInterval(poll, 1000);
}
poll();
};
const stopPolling = () => {
if (interval !== null) {
clearInterval(interval);
interval = null;
}
};
const poll = () => {
let plaintext;
try {
const data = Buffer.from(clipboard.readText(), 'hex');
const iv = data.slice(0, 12);
const ciphertext = data.slice(12, -16);
const authTag = data.slice(-16);
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.key,
iv,
{authTagLength: 16}
);
decipher.setAuthTag(authTag);
plaintext =
decipher.update(ciphertext, undefined, 'utf8') +
decipher.final('utf8');
} catch (_) {
// If the parsing or decryption failed in any way,
// the correct token hasnt been copied yet; try
// again next time.
return;
}
window.removeEventListener('focus', startPolling);
window.removeEventListener('blur', stopPolling);
stopPolling();
resolve(plaintext);
};
window.addEventListener('focus', startPolling);
window.addEventListener('blur', stopPolling);
if (document.hasFocus()) {
startPolling();
}
});
}
}

View File

@@ -1,7 +1,11 @@
export default class BaseComponent {
'use strict';
class BaseComponent {
generateNodeFromTemplate(template: string): Element | null {
const wrapper = document.createElement('div');
wrapper.innerHTML = template;
return wrapper.firstElementChild;
}
}
export = BaseComponent;

View File

@@ -1,13 +1,9 @@
import Tab, {TabProps} from './tab';
'use strict';
export default class FunctionalTab extends Tab {
import Tab = require('./tab');
class FunctionalTab extends Tab {
$closeButton: Element;
constructor(props: TabProps) {
super(props);
this.init();
}
template(): string {
return `<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button">
@@ -19,6 +15,12 @@ export default class FunctionalTab extends Tab {
</div>`;
}
// TODO: Typescript - This type for props should be TabProps
constructor(props: any) {
super(props);
this.init();
}
init(): void {
this.$el = this.generateNodeFromTemplate(this.template());
if (this.props.name !== 'Settings') {
@@ -39,9 +41,11 @@ export default class FunctionalTab extends Tab {
this.$closeButton.classList.remove('active');
});
this.$closeButton.addEventListener('click', (event: Event) => {
this.$closeButton.addEventListener('click', (e: Event) => {
this.props.onDestroy();
event.stopPropagation();
e.stopPropagation();
});
}
}
export = FunctionalTab;

View File

@@ -1,60 +1,83 @@
import {ipcRenderer, remote} from 'electron';
import { ipcRenderer, remote } from 'electron';
import * as LinkUtil from '../utils/link-util';
import * as ConfigUtil from '../utils/config-util';
import type WebView from './webview';
import LinkUtil = require('../utils/link-util');
import DomainUtil = require('../utils/domain-util');
import ConfigUtil = require('../utils/config-util');
const {shell, app} = remote;
const { shell, app } = remote;
const dingSound = new Audio('../resources/sounds/ding.ogg');
export default function handleExternalLink(this: WebView, event: Electron.NewWindowEvent): void {
event.preventDefault();
const url = new URL(event.url);
// TODO: TypeScript - Figure out a way to pass correct type here.
function handleExternalLink(this: any, event: any): void {
const { url } = event;
const domainPrefix = DomainUtil.getDomain(this.props.index).url;
const downloadPath = ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`);
const shouldShowInFolder = ConfigUtil.getConfigItem('showDownloadFolder', false);
if (LinkUtil.isUploadsUrl(this.props.url, url)) {
ipcRenderer.send('downloadFile', url.href, downloadPath);
ipcRenderer.once('downloadFileCompleted', async (_event: Event, filePath: string, fileName: string) => {
const downloadNotification = new Notification('Download Complete', {
body: `Click to show ${fileName} in folder`,
silent: true // We'll play our own sound - ding.ogg
});
// Whitelist URLs which are allowed to be opened in the app
const {
isInternalUrl: isWhiteListURL,
isUploadsUrl: isUploadsURL
} = LinkUtil.isInternal(domainPrefix, url);
downloadNotification.addEventListener('click', () => {
// Reveal file in download folder
shell.showItemInFolder(filePath);
});
ipcRenderer.removeAllListeners('downloadFileFailed');
if (isWhiteListURL) {
event.preventDefault();
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem('silent')) {
await dingSound.play();
}
});
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
// Show pdf attachments in a new window
// if (LinkUtil.isPDF(url) && isUploadsURL) {
// ipcRenderer.send('pdf-view', url);
// return;
// }
ipcRenderer.once('downloadFileFailed', (_event: Event, state: string) => {
// Automatic download failed, so show save dialog prompt and download
// through webview
// Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save
// prompts right after each other)
// Check that the download is not cancelled by user
if (state !== 'cancelled') {
if (ConfigUtil.getConfigItem('promptDownload', false)) {
// We need to create a "new Notification" to display it, but just `Notification(...)` on its own
// doesn't work
new Notification('Download Complete', { // eslint-disable-line no-new
body: 'Download failed'
});
} else {
this.$el.downloadURL(url.href);
// download txt, mp3, mp4 etc.. by using downloadURL in the
// main process which allows the user to save the files to their desktop
// and not trigger webview reload while image in webview will
// do nothing and will not save it
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
// if (!LinkUtil.isImage(url) && !LinkUtil.isPDF(url) && isUploadsURL) {
if (!LinkUtil.isImage(url) && isUploadsURL) {
ipcRenderer.send('downloadFile', url, downloadPath);
ipcRenderer.once('downloadFileCompleted', (_event: Event, filePath: string, fileName: string) => {
const downloadNotification = new Notification('Download Complete', {
body: shouldShowInFolder ? `Click to show ${fileName} in folder` : `Click to open ${fileName}`,
silent: true // We'll play our own sound - ding.ogg
});
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem('silent')) {
dingSound.play();
}
}
ipcRenderer.removeAllListeners('downloadFileCompleted');
});
downloadNotification.addEventListener('click', () => {
if (shouldShowInFolder) {
// Reveal file in download folder
shell.showItemInFolder(filePath);
} else {
// Open file in the default native app
shell.openItem(filePath);
}
});
ipcRenderer.removeAllListeners('downloadFileFailed');
});
ipcRenderer.once('downloadFileFailed', () => {
// Automatic download failed, so show save dialog prompt and download
// through webview
this.$el.downloadURL(url);
ipcRenderer.removeAllListeners('downloadFileCompleted');
});
return;
}
// open internal urls inside the current webview.
this.$el.loadURL(url);
} else {
(async () => LinkUtil.openBrowser(url))();
event.preventDefault();
shell.openExternal(url);
}
}
export = handleExternalLink;

View File

@@ -1,16 +1,13 @@
import {ipcRenderer} from 'electron';
'use strict';
import Tab, {TabProps} from './tab';
import * as SystemUtil from '../utils/system-util';
import { ipcRenderer } from 'electron';
export default class ServerTab extends Tab {
import Tab = require('./tab');
import SystemUtil = require('../utils/system-util');
class ServerTab extends Tab {
$badge: Element;
constructor(props: TabProps) {
super(props);
this.init();
}
template(): string {
return `<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">${this.props.name}</div>
@@ -22,6 +19,12 @@ export default class ServerTab extends Tab {
</div>`;
}
// TODO: Typescript - This type for props should be TabProps
constructor(props: any) {
super(props);
this.init();
}
init(): void {
this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$el);
@@ -61,3 +64,5 @@ export default class ServerTab extends Tab {
return shortcutText;
}
}
export = ServerTab;

View File

@@ -1,22 +1,14 @@
import WebView from './webview';
import BaseComponent from './base';
'use strict';
export interface TabProps {
role: string;
icon?: string;
name: string;
$root: Element;
onClick: () => void;
index: number;
tabIndex: number;
onHover?: () => void;
onHoverOut?: () => void;
webview: WebView;
materialIcon?: string;
onDestroy?: () => void;
import WebView = require('./webview');
import BaseComponent = require('./base');
// TODO: TypeScript - Type annotate props
interface TabProps {
[key: string]: any;
}
export default class Tab extends BaseComponent {
class Tab extends BaseComponent {
props: TabProps;
webview: WebView;
$el: Element;
@@ -48,7 +40,9 @@ export default class Tab extends BaseComponent {
}
destroy(): void {
this.$el.remove();
this.webview.$el.remove();
this.$el.parentNode.removeChild(this.$el);
this.webview.$el.parentNode.removeChild(this.webview.$el);
}
}
export = Tab;

View File

@@ -1,48 +1,40 @@
import {ipcRenderer, remote} from 'electron';
'use strict';
import { remote } from 'electron';
import path from 'path';
import fs from 'fs';
import * as ConfigUtil from '../utils/config-util';
import * as SystemUtil from '../utils/system-util';
import BaseComponent from './base';
import handleExternalLink from './handle-external-link';
import path = require('path');
import fs = require('fs');
import ConfigUtil = require('../utils/config-util');
import SystemUtil = require('../utils/system-util');
import BaseComponent = require('../components/base');
import handleExternalLink = require('../components/handle-external-link');
const {app, dialog} = remote;
const { app, dialog } = remote;
const shouldSilentWebview = ConfigUtil.getConfigItem('silent');
// TODO: TypeScript - Type annotate WebViewProps.
interface WebViewProps {
$root: Element;
index: number;
tabIndex: number;
url: string;
role: string;
name: string;
isActive: () => boolean;
switchLoading: (loading: boolean, url: string) => void;
onNetworkError: (index: number) => void;
nodeIntegration: boolean;
preload: boolean;
onTitleChange: () => void;
ignoreCerts?: boolean;
hasPermission?: (origin: string, permission: string) => boolean;
[key: string]: any;
}
export default class WebView extends BaseComponent {
props: WebViewProps;
class WebView extends BaseComponent {
props: any;
zoomFactor: number;
badgeCount: number;
loading: boolean;
customCSS: string;
$webviewsContainer: DOMTokenList;
$el: Electron.WebviewTag;
domReady?: Promise<void>;
// This is required because in main.js we access WebView.method as
// webview[method].
[key: string]: any;
constructor(props: WebViewProps) {
super();
this.props = props;
this.zoomFactor = 1;
this.zoomFactor = 1.0;
this.loading = true;
this.badgeCount = 0;
this.customCSS = ConfigUtil.getConfigItem('customCSS');
@@ -58,15 +50,12 @@ export default class WebView extends BaseComponent {
${this.props.preload ? 'preload="js/preload.js"' : ''}
partition="persist:webviewsession"
name="${this.props.name}"
webpreferences="${this.props.nodeIntegration ? '' : 'contextIsolation, '}javascript=yes">
webpreferences="allowRunningInsecureContent, javascript=yes">
</webview>`;
}
init(): void {
this.$el = this.generateNodeFromTemplate(this.template()) as Electron.WebviewTag;
this.domReady = new Promise(resolve => {
this.$el.addEventListener('dom-ready', () => resolve(), true);
});
this.props.$root.append(this.$el);
this.registerListeners();
@@ -84,7 +73,7 @@ export default class WebView extends BaseComponent {
}
this.$el.addEventListener('page-title-updated', event => {
const {title} = event;
const { title } = event;
this.badgeCount = this.getBadgeCount(title);
this.props.onTitleChange();
});
@@ -94,7 +83,6 @@ export default class WebView extends BaseComponent {
if (isSettingPage) {
return;
}
this.canGoBackButton();
});
@@ -103,14 +91,14 @@ export default class WebView extends BaseComponent {
});
this.$el.addEventListener('page-favicon-updated', event => {
const {favicons} = event;
const { favicons } = event;
// This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like
// https://chat.zulip.org/static/images/favicon/favicon-pms.png
if (favicons[0].indexOf('favicon-pms') > 0 && process.platform === 'darwin') {
// This api is only supported on macOS
app.dock.setBadge('●');
// Bounce the dock
// bounce the dock
if (ConfigUtil.getConfigItem('dockBouncing')) {
app.dock.bounce();
}
@@ -121,7 +109,6 @@ export default class WebView extends BaseComponent {
if (this.props.role === 'server') {
this.$el.classList.add('onload');
}
this.loading = false;
this.props.switchLoading(false, this.props.url);
this.show();
@@ -133,7 +120,7 @@ export default class WebView extends BaseComponent {
});
this.$el.addEventListener('did-fail-load', event => {
const {errorDescription} = event;
const { errorDescription } = event;
const hasConnectivityErr = SystemUtil.connectivityERR.includes(errorDescription);
if (hasConnectivityErr) {
console.error('error', errorDescription);
@@ -148,6 +135,12 @@ export default class WebView extends BaseComponent {
if (!isSettingPage) {
this.props.switchLoading(true, this.props.url);
}
let userAgent = SystemUtil.getUserAgent();
if (!userAgent) {
SystemUtil.setUserAgent(this.$el.getUserAgent());
userAgent = SystemUtil.getUserAgent();
}
this.$el.setUserAgent(userAgent);
});
this.$el.addEventListener('did-stop-loading', () => {
@@ -161,7 +154,7 @@ export default class WebView extends BaseComponent {
}
showNotificationSettings(): void {
ipcRenderer.sendTo(this.$el.getWebContentsId(), 'show-notification-settings');
this.$el.executeJavaScript('showNotificationSettings()');
}
show(): void {
@@ -187,26 +180,26 @@ export default class WebView extends BaseComponent {
this.focus();
this.props.onTitleChange();
// Injecting preload css in webview to override some css rules
(async () => this.$el.insertCSS(fs.readFileSync(path.join(__dirname, '/../../css/preload.css'), 'utf8')))();
this.$el.insertCSS(fs.readFileSync(path.join(__dirname, '/../../css/preload.css'), 'utf8'));
// Get customCSS again from config util to avoid warning user again
// get customCSS again from config util to avoid warning user again
this.customCSS = ConfigUtil.getConfigItem('customCSS');
if (this.customCSS) {
if (!fs.existsSync(this.customCSS)) {
this.customCSS = null;
ConfigUtil.setConfigItem('customCSS', null);
const errorMessage = 'The custom css previously set is deleted!';
dialog.showErrorBox('custom css file deleted!', errorMessage);
const errMsg = 'The custom css previously set is deleted!';
dialog.showErrorBox('custom css file deleted!', errMsg);
return;
}
(async () => this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8')))();
this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8'));
}
}
focus(): void {
// Focus Webview and it's contents when Window regain focus.
// focus Webview and it's contents when Window regain focus.
const webContents = this.$el.getWebContents();
// HACK: webContents.isFocused() seems to be true even without the element
// being in focus. So, we check against `document.activeElement`.
@@ -244,16 +237,16 @@ export default class WebView extends BaseComponent {
}
zoomActualSize(): void {
this.zoomFactor = 1;
this.zoomFactor = 1.0;
this.$el.setZoomFactor(this.zoomFactor);
}
logOut(): void {
ipcRenderer.sendTo(this.$el.getWebContentsId(), 'logout');
this.$el.executeJavaScript('logout()');
}
showShortcut(): void {
ipcRenderer.sendTo(this.$el.getWebContentsId(), 'shortcut');
this.$el.executeJavaScript('shortcut()');
}
openDevTools(): void {
@@ -295,8 +288,9 @@ export default class WebView extends BaseComponent {
this.init();
}
async send(channel: string, ...parameters: unknown[]): Promise<void> {
await this.domReady;
await this.$el.send(channel, ...parameters);
send(channel: string, ...param: any[]): void {
this.$el.send(channel, ...param);
}
}
export = WebView;

View File

@@ -1,13 +1,14 @@
import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron';
import {EventEmitter} from 'events';
import {ClipboardDecrypterImpl} from './clipboard-decrypter';
import {NotificationData, newNotification} from './notification';
import events = require('events');
type ListenerType = ((...args: any[]) => void);
class ElectronBridgeImpl extends EventEmitter implements ElectronBridge {
// we have and will have some non camelcase stuff
// while working with zulip so just turning the rule off
// for the whole file.
/* eslint-disable @typescript-eslint/camelcase */
class ElectronBridge extends events {
send_notification_reply_message_supported: boolean;
idle_on_system: boolean;
last_active_on_system: number;
@@ -22,37 +23,16 @@ class ElectronBridgeImpl extends EventEmitter implements ElectronBridge {
this.last_active_on_system = Date.now();
}
send_event = (eventName: string | symbol, ...args: unknown[]): void => {
send_event(eventName: string | symbol, ...args: any[]): void {
this.emit(eventName, ...args);
};
}
on_event = (eventName: string, listener: ListenerType): void => {
on_event(eventName: string, listener: ListenerType): void {
this.on(eventName, listener);
};
new_notification = (
title: string,
options: NotificationOptions | undefined,
dispatch: (type: string, eventInit: EventInit) => boolean
): NotificationData =>
newNotification(title, options, dispatch);
get_idle_on_system = (): boolean => this.idle_on_system;
get_last_active_on_system = (): number => this.last_active_on_system;
get_send_notification_reply_message_supported = (): boolean =>
this.send_notification_reply_message_supported;
set_send_notification_reply_message_supported = (value: boolean): void => {
this.send_notification_reply_message_supported = value;
};
decrypt_clipboard = (version: number): ClipboardDecrypterImpl =>
new ClipboardDecrypterImpl(version);
}
}
const electron_bridge = new ElectronBridgeImpl();
const electron_bridge = new ElectronBridge();
electron_bridge.on('total_unread_count', (...args) => {
ipcRenderer.send('unread-count', ...args);
@@ -63,19 +43,17 @@ electron_bridge.on('realm_name', realmName => {
ipcRenderer.send('realm-name-changed', serverURL, realmName);
});
electron_bridge.on('realm_icon_url', (iconURL: unknown) => {
if (typeof iconURL !== 'string') {
throw new TypeError('Expected string for iconURL');
}
electron_bridge.on('realm_icon_url', iconURL => {
const serverURL = location.origin;
iconURL = iconURL.includes('http') ? iconURL : `${serverURL}${iconURL}`;
ipcRenderer.send('realm-icon-changed', serverURL, iconURL);
});
// This follows node's idiomatic implementation of event
// this follows node's idiomatic implementation of event
// emitters to make event handling more simpler instead of using
// functions zulip side will emit event using ElectronBrigde.send_event
// which is alias of .emit and on this side we can handle the data by adding
// a listener for the event.
export default electron_bridge;
export = electron_bridge;
/* eslint-enable @typescript-eslint/camelcase */

View File

@@ -1,17 +1,19 @@
import {remote} from 'electron';
import { remote } from 'electron';
import SendFeedback from '@electron-elements/send-feedback';
import path from 'path';
import fs from 'fs';
import path = require('path');
import fs = require('fs');
const {app} = remote;
const { app } = remote;
customElements.define('send-feedback', SendFeedback);
export const sendFeedback: SendFeedback = document.querySelector('send-feedback');
export const feedbackHolder = sendFeedback.parentElement;
interface SendFeedback extends HTMLElement {
[key: string]: any;
}
// Make the button color match zulip app's theme
sendFeedback.customStyles = `
type SendFeedbackType = SendFeedback;
// make the button color match zulip app's theme
SendFeedback.customStyles = `
button:hover, button:focus {
border-color: #4EBFAC;
color: #4EBFAC;
@@ -28,26 +30,16 @@ button {
}
`;
/* eslint-disable no-multi-str */
customElements.define('send-feedback', SendFeedback);
export const sendFeedback: SendFeedbackType = document.querySelector('send-feedback');
export const feedbackHolder = sendFeedback.parentElement;
// customize the fields of custom elements
sendFeedback.title = 'Report Issue';
sendFeedback.titleLabel = 'Issue title:';
sendFeedback.titlePlaceholder = 'Enter issue title';
sendFeedback.textareaLabel = 'Describe the issue:';
sendFeedback.textareaPlaceholder = 'Succinctly describe your issue and steps to reproduce it...\n\n\
---\n\
<!-- Please Include: -->\n\
- **Operating System**:\n\
- [ ] Windows\n\
- [ ] Linux/Ubuntu\n\
- [ ] macOS\n\
- **Clear steps to reproduce the issue**:\n\
- **Relevant error messages and/or screenshots**:\n\
';
/* eslint-enable no-multi-str */
sendFeedback.textareaPlaceholder = 'Succinctly describe your issue and steps to reproduce it...';
sendFeedback.buttonLabel = 'Report Issue';
sendFeedback.loaderSuccessText = '';
@@ -55,10 +47,10 @@ sendFeedback.useReporter('emailReporter', {
email: 'akash@zulipchat.com'
});
feedbackHolder.addEventListener('click', (event: Event) => {
// Only remove the class if the grey out faded
feedbackHolder.addEventListener('click', (e: Event) => {
// only remove the class if the grey out faded
// part is clicked and not the feedback element itself
if (event.target === event.currentTarget) {
if (e.target === e.currentTarget) {
feedbackHolder.classList.remove('show');
}
});

View File

@@ -1,135 +0,0 @@
'use strict';
interface CompatElectronBridge extends ElectronBridge {
readonly idle_on_system: boolean;
readonly last_active_on_system: number;
send_notification_reply_message_supported: boolean;
}
(() => {
const zulipWindow = window as typeof window & {
electron_bridge: CompatElectronBridge;
narrow: {
by_subject?: (target_id: number, opts: {trigger?: string}) => void;
by_topic?: (target_id: number, opts: {trigger?: string}) => void;
};
page_params?: {
default_language?: string;
};
raw_electron_bridge: ElectronBridge;
};
const electron_bridge: CompatElectronBridge = {
...zulipWindow.raw_electron_bridge,
get idle_on_system(): boolean {
return this.get_idle_on_system();
},
get last_active_on_system(): number {
return this.get_last_active_on_system();
},
get send_notification_reply_message_supported(): boolean {
return this.get_send_notification_reply_message_supported();
},
set send_notification_reply_message_supported(value: boolean) {
this.set_send_notification_reply_message_supported(value);
}
};
zulipWindow.electron_bridge = electron_bridge;
(async () => {
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', () => {
resolve();
});
});
}
const {page_params} = zulipWindow;
if (page_params) {
electron_bridge.send_event('zulip-loaded', {
serverLanguage: page_params.default_language
});
}
})();
electron_bridge.on_event('narrow-by-topic', (id: number) => {
const {narrow} = zulipWindow;
const narrowByTopic = narrow.by_topic || narrow.by_subject;
narrowByTopic(id, {trigger: 'notification'});
});
function attributeListener<T extends EventTarget>(type: string): PropertyDescriptor {
const symbol = Symbol('on' + type);
function listener(this: T, ev: Event): void {
if ((this as any)[symbol].call(this, ev) === false) {
ev.preventDefault();
}
}
return {
configurable: true,
enumerable: true,
get(this: T) {
return (this as any)[symbol];
},
set(this: T, value: unknown) {
if (typeof value === 'function') {
if (!(symbol in this)) {
this.addEventListener(type, listener);
}
(this as any)[symbol] = value;
} else if (symbol in this) {
this.removeEventListener(type, listener);
delete (this as any)[symbol];
}
}
};
}
const NativeNotification = Notification;
class InjectedNotification extends EventTarget {
constructor(title: string, options?: NotificationOptions) {
super();
Object.assign(
this,
electron_bridge.new_notification(title, options, (type: string, eventInit: EventInit) =>
this.dispatchEvent(new Event(type, eventInit))
)
);
}
static get maxActions(): number {
return NativeNotification.maxActions;
}
static get permission(): NotificationPermission {
return NativeNotification.permission;
}
static async requestPermission(callback?: NotificationPermissionCallback): Promise<NotificationPermission> {
if (callback) {
callback(await Promise.resolve(NativeNotification.permission));
}
return NativeNotification.permission;
}
}
Object.defineProperties(InjectedNotification.prototype, {
onclick: attributeListener('click'),
onclose: attributeListener('close'),
onerror: attributeListener('error'),
onshow: attributeListener('show')
});
window.Notification = InjectedNotification as any;
})();

View File

@@ -1,27 +1,27 @@
import {ipcRenderer, remote, clipboard} from 'electron';
import {feedbackHolder} from './feedback';
'use strict';
import path from 'path';
import escape from 'escape-html';
import isDev from 'electron-is-dev';
const {session, app, Menu, dialog} = remote;
import { ipcRenderer, remote, clipboard, shell } from 'electron';
import { feedbackHolder } from './feedback';
import path = require('path');
import escape = require('escape-html');
import isDev = require('electron-is-dev');
const { session, app, Menu, dialog } = remote;
// eslint-disable-next-line import/no-unassigned-import
import './tray';
require('./tray');
import * as DomainUtil from './utils/domain-util';
import WebView from './components/webview';
import ServerTab from './components/server-tab';
import FunctionalTab from './components/functional-tab';
import * as ConfigUtil from './utils/config-util';
import * as DNDUtil from './utils/dnd-util';
import ReconnectUtil from './utils/reconnect-util';
import Logger from './utils/logger-util';
import * as CommonUtil from './utils/common-util';
import * as EnterpriseUtil from './utils/enterprise-util';
import * as LinkUtil from './utils/link-util';
import * as Messages from '../../resources/messages';
import type {DNDSettings} from './utils/dnd-util';
import DomainUtil = require('./utils/domain-util');
import WebView = require('./components/webview');
import ServerTab = require('./components/server-tab');
import FunctionalTab = require('./components/functional-tab');
import ConfigUtil = require('./utils/config-util');
import DNDUtil = require('./utils/dnd-util');
import ReconnectUtil = require('./utils/reconnect-util');
import Logger = require('./utils/logger-util');
import CommonUtil = require('./utils/common-util');
import EnterpriseUtil = require('./utils/enterprise-util');
import Messages = require('./../../resources/messages');
interface FunctionalTabProps {
name: string;
@@ -29,7 +29,11 @@ interface FunctionalTabProps {
url: string;
}
interface SettingsOptions extends DNDSettings {
interface AnyObject {
[key: string]: any;
}
interface SettingsOptions {
autoHideMenubar: boolean;
trayIcon: boolean;
useManualProxy: boolean;
@@ -39,17 +43,25 @@ interface SettingsOptions extends DNDSettings {
startAtLogin: boolean;
startMinimized: boolean;
enableSpellchecker: boolean;
showNotification: boolean;
autoUpdate: boolean;
betaUpdate: boolean;
errorReporting: boolean;
customCSS: boolean;
silent: boolean;
lastActiveTab: number;
dnd: boolean;
dndPreviousSettings: DNDSettings;
dndPreviousSettings: {
showNotification: boolean;
silent: boolean;
flashTaskbarOnMessage?: boolean;
};
downloadsPath: string;
showDownloadFolder: boolean;
quitOnClose: boolean;
promptDownload: boolean;
flashTaskbarOnMessage?: boolean;
dockBouncing?: boolean;
loading?: AnyObject;
}
const logger = new Logger({
@@ -58,7 +70,7 @@ const logger = new Logger({
});
const rendererDirectory = path.resolve(__dirname, '..');
export type ServerOrFunctionalTab = ServerTab | FunctionalTab;
type ServerOrFunctionalTab = ServerTab | FunctionalTab;
class ServerManagerView {
$addServerButton: HTMLButtonElement;
@@ -79,10 +91,10 @@ class ServerManagerView {
$sidebar: Element;
$fullscreenPopup: Element;
$fullscreenEscapeKey: string;
loading: Set<string>;
loading: AnyObject;
activeTabIndex: number;
tabs: ServerOrFunctionalTab[];
functionalTabs: Map<string, number>;
functionalTabs: AnyObject;
tabIndex: number;
presetOrgs: string[];
constructor() {
@@ -117,54 +129,54 @@ class ServerManagerView {
this.$fullscreenEscapeKey = process.platform === 'darwin' ? '^⌘F' : 'F11';
this.$fullscreenPopup.innerHTML = `Press ${this.$fullscreenEscapeKey} to exit full screen`;
this.loading = new Set();
this.loading = {};
this.activeTabIndex = -1;
this.tabs = [];
this.presetOrgs = [];
this.functionalTabs = new Map();
this.functionalTabs = {};
this.tabIndex = 0;
}
async init(): Promise<void> {
await this.loadProxy();
this.initDefaultSettings();
this.initSidebar();
this.removeUAfromDisk();
if (EnterpriseUtil.hasConfigFile()) {
await this.initPresetOrgs();
}
await this.initTabs();
this.initActions();
this.registerIpcs();
init(): void {
this.loadProxy().then(() => {
this.initDefaultSettings();
this.initSidebar();
if (EnterpriseUtil.configFile) {
this.initPresetOrgs();
}
this.initTabs();
this.initActions();
this.registerIpcs();
});
}
async loadProxy(): Promise<void> {
// To change proxyEnable to useManualProxy in older versions
const proxyEnabledOld = ConfigUtil.isConfigItemExists('useProxy');
if (proxyEnabledOld) {
const proxyEnableOldState = ConfigUtil.getConfigItem('useProxy');
if (proxyEnableOldState) {
ConfigUtil.setConfigItem('useManualProxy', true);
loadProxy(): Promise<boolean> {
return new Promise(resolve => {
// To change proxyEnable to useManualProxy in older versions
const proxyEnabledOld = ConfigUtil.isConfigItemExists('useProxy');
if (proxyEnabledOld) {
const proxyEnableOldState = ConfigUtil.getConfigItem('useProxy');
if (proxyEnableOldState) {
ConfigUtil.setConfigItem('useManualProxy', true);
}
ConfigUtil.removeConfigItem('useProxy');
}
ConfigUtil.removeConfigItem('useProxy');
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
if (proxyEnabled) {
await session.fromPartition('persist:webviewsession').setProxy({
pacScript: ConfigUtil.getConfigItem('proxyPAC', ''),
proxyRules: ConfigUtil.getConfigItem('proxyRules', ''),
proxyBypassRules: ConfigUtil.getConfigItem('proxyBypass', '')
});
} else {
await session.fromPartition('persist:webviewsession').setProxy({
pacScript: '',
proxyRules: '',
proxyBypassRules: ''
});
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
if (proxyEnabled) {
session.fromPartition('persist:webviewsession').setProxy({
pacScript: ConfigUtil.getConfigItem('proxyPAC', ''),
proxyRules: ConfigUtil.getConfigItem('proxyRules', ''),
proxyBypassRules: ConfigUtil.getConfigItem('proxyBypass', '')
}, resolve);
} else {
session.fromPartition('persist:webviewsession').setProxy({
pacScript: '',
proxyRules: '',
proxyBypassRules: ''
}, resolve);
}
});
}
// Settings are initialized only when user clicks on General/Server/Network section settings
@@ -195,8 +207,8 @@ class ServerManagerView {
silent: false
},
downloadsPath: `${app.getPath('downloads')}`,
quitOnClose: false,
promptDownload: false
showDownloadFolder: false,
quitOnClose: false
};
// Platform specific settings
@@ -216,12 +228,13 @@ class ServerManagerView {
settingOptions.autoHideMenubar = false;
}
for (const [setting, value] of Object.entries(settingOptions)) {
// Give preference to defaults defined in global_config.json
for (const i in settingOptions) {
const setting = i as keyof SettingsOptions;
// give preference to defaults defined in global_config.json
if (EnterpriseUtil.configItemExists(setting)) {
ConfigUtil.setConfigItem(setting, EnterpriseUtil.getConfigItem(setting), true);
} else if (ConfigUtil.getConfigItem(setting) === null) {
ConfigUtil.setConfigItem(setting, value);
ConfigUtil.setConfigItem(setting, settingOptions[setting]);
}
}
}
@@ -231,122 +244,110 @@ class ServerManagerView {
this.toggleSidebar(showSidebar);
}
// Remove the stale UA string from the disk if the app is not freshly
// installed. This should be removed in a further release.
removeUAfromDisk(): void {
ConfigUtil.removeConfigItem('userAgent');
}
async queueDomain(domain: string): Promise<boolean> {
// Allows us to start adding multiple domains to the app simultaneously
async queueDomain(domain: any): Promise<boolean> {
// allows us to start adding multiple domains to the app simultaneously
// promise of addition resolves in both cases, but we consider it rejected
// if the resolved value is false
try {
const serverConf = await DomainUtil.checkDomain(domain);
await DomainUtil.addDomain(serverConf);
return true;
} catch (error) {
logger.error(error);
logger.error(`Could not add ${domain}. Please contact your system administrator.`);
} catch (err) {
logger.error(err);
logger.error('Could not add ' + domain + '. Please contact your system administrator.');
return false;
}
}
async initPresetOrgs(): Promise<void> {
// Read preset organizations from global_config.json and queues them
// read preset organizations from global_config.json and queues them
// for addition to the app's domains
const preAddedDomains = DomainUtil.getDomains();
this.presetOrgs = EnterpriseUtil.getConfigItem('presetOrganizations', []);
// Set to true if at least one new domain is added
// set to true if at least one new domain is added
const domainPromises = [];
for (const url of this.presetOrgs) {
if (DomainUtil.duplicateDomain(url)) {
continue;
}
domainPromises.push(this.queueDomain(url));
}
const domainsAdded = await Promise.all(domainPromises);
if (domainsAdded.includes(true)) {
// At least one domain was resolved
// at least one domain was resolved
if (preAddedDomains.length > 0) {
// User already has servers added
// user already has servers added
// ask them before reloading the app
const {response} = await dialog.showMessageBox({
dialog.showMessageBox({
type: 'question',
buttons: ['Yes', 'Later'],
defaultId: 0,
message: 'New server' + (domainsAdded.length > 1 ? 's' : '') + ' added. Reload app now?'
}, response => {
if (response === 0) {
ipcRenderer.send('reload-full-app');
}
});
if (response === 0) {
ipcRenderer.send('reload-full-app');
}
} else {
ipcRenderer.send('reload-full-app');
}
} else if (domainsAdded.length > 0) {
// Find all orgs that failed
// find all orgs that failed
const failedDomains: string[] = [];
for (const org of this.presetOrgs) {
if (DomainUtil.duplicateDomain(org)) {
continue;
}
failedDomains.push(org);
}
const {title, content} = Messages.enterpriseOrgError(domainsAdded.length, failedDomains);
const { title, content } = Messages.enterpriseOrgError(domainsAdded.length, failedDomains);
dialog.showErrorBox(title, content);
if (DomainUtil.getDomains().length === 0) {
// No orgs present, stop showing loading gif
await this.openSettings('AddServer');
// no orgs present, stop showing loading gif
this.openSettings('AddServer');
}
}
}
async initTabs(): Promise<void> {
initTabs(): void {
const servers = DomainUtil.getDomains();
if (servers.length > 0) {
for (const [i, server] of servers.entries()) {
this.initServer(server, i);
for (let i = 0; i < servers.length; i++) {
this.initServer(servers[i], i);
}
// Open last active tab
let lastActiveTab = ConfigUtil.getConfigItem('lastActiveTab');
if (lastActiveTab >= servers.length) {
lastActiveTab = 0;
}
// `checkDomain()` and `webview.load()` for lastActiveTab before the others
await DomainUtil.updateSavedServer(servers[lastActiveTab].url, lastActiveTab);
// checkDomain() and webview.load() for lastActiveTab before the others
DomainUtil.updateSavedServer(servers[lastActiveTab].url, lastActiveTab);
this.activateTab(lastActiveTab);
await Promise.all(servers.map(async (server, i) => {
// After the lastActiveTab is activated, we load the others in the background
for (let i = 0; i < servers.length; i++) {
// after the lastActiveTab is activated, we load the others in the background
// without activating them, to prevent flashing of server icons
if (i === lastActiveTab) {
return;
continue;
}
await DomainUtil.updateSavedServer(server.url, i);
DomainUtil.updateSavedServer(servers[i].url, i);
this.tabs[i].webview.load();
}));
}
// Remove focus from the settings icon at sidebar bottom
this.$settingsButton.classList.remove('active');
} else if (this.presetOrgs.length === 0) {
// Not attempting to add organisations in the background
await this.openSettings('AddServer');
// not attempting to add organisations in the background
this.openSettings('AddServer');
} else {
this.showLoading(true);
}
}
initServer(server: DomainUtil.ServerConf, index: number): void {
initServer(server: any, index: number): void {
const tabIndex = this.getTabIndex();
this.tabs.push(new ServerTab({
role: 'server',
icon: server.icon,
name: CommonUtil.decodeString(server.alias),
name: server.alias,
$root: this.$tabsContainer,
onClick: this.activateLastTab.bind(this, index),
index,
@@ -360,20 +361,16 @@ class ServerManagerView {
url: server.url,
role: 'server',
name: CommonUtil.decodeString(server.alias),
ignoreCerts: server.ignoreCerts,
hasPermission: (origin: string, permission: string) =>
origin === server.url && permission === 'notifications',
isActive: () => {
return index === this.activeTabIndex;
},
switchLoading: (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
if (!loading && this.loading[url]) {
this.loading[url] = false;
} else if (loading && !this.loading[url]) {
this.loading[url] = true;
}
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
this.showLoading(this.loading[this.tabs[this.activeTabIndex].webview.props.url]);
},
onNetworkError: (index: number) => this.openNetworkTroubleshooting(index),
onTitleChange: this.updateBadge.bind(this),
@@ -381,7 +378,7 @@ class ServerManagerView {
preload: true
})
}));
this.loading.add(server.url);
this.loading[server.url] = true;
}
initActions(): void {
@@ -397,7 +394,6 @@ class ServerManagerView {
if ($serverImg.src.includes('img/icon.png')) {
this.displayInitialCharLogo($serverImg, index);
}
$serverImg.addEventListener('error', () => {
this.displayInitialCharLogo($serverImg, index);
});
@@ -412,11 +408,11 @@ class ServerManagerView {
this.$reloadButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.reload();
});
this.$addServerButton.addEventListener('click', async () => {
await this.openSettings('AddServer');
this.$addServerButton.addEventListener('click', () => {
this.openSettings('AddServer');
});
this.$settingsButton.addEventListener('click', async () => {
await this.openSettings('General');
this.$settingsButton.addEventListener('click', () => {
this.openSettings('General');
});
this.$backButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.back();
@@ -446,9 +442,10 @@ class ServerManagerView {
}
displayInitialCharLogo($img: HTMLImageElement, index: number): void {
// The index parameter is needed because webview[data-tab-id] can
// increment beyond the size of the sidebar org array and throw an
// error
/*
index parameter needed because webview[data-tab-id] can increment
beyond size of sidebar org array and throw error
*/
const $altIcon = document.createElement('div');
const $parent = $img.parentElement;
@@ -466,7 +463,7 @@ class ServerManagerView {
$altIcon.classList.add('server-icon');
$altIcon.classList.add('alt-icon');
$img.remove();
$parent.removeChild($img);
$parent.append($altIcon);
this.addContextMenu($altIcon as HTMLImageElement, index);
@@ -480,8 +477,8 @@ class ServerManagerView {
// as that of its parent element.
// This needs to handled only for the add server tooltip and not others.
if (addServer) {
const {top} = SidebarButton.getBoundingClientRect();
SidebarTooltip.style.top = `${top}px`;
const { top } = SidebarButton.getBoundingClientRect();
SidebarTooltip.style.top = top + 'px';
}
});
SidebarButton.addEventListener('mouseout', () => {
@@ -490,14 +487,14 @@ class ServerManagerView {
}
onHover(index: number): void {
// `this.$serverIconTooltip[index].innerHTML` already has realm name, so we are just
// this.$serverIconTooltip[index].innerHTML already has realm name, so we are just
// removing the style.
this.$serverIconTooltip[index].removeAttribute('style');
// To handle position of servers' tooltip due to scrolling of list of organizations
// This could not be handled using CSS, hence the top of the tooltip is made same
// as that of its parent element.
const {top} = this.$serverIconTooltip[index].parentElement.getBoundingClientRect();
this.$serverIconTooltip[index].style.top = `${top}px`;
const { top } = this.$serverIconTooltip[index].parentElement.getBoundingClientRect();
this.$serverIconTooltip[index].style.top = top + 'px';
}
onHoverOut(index: number): void {
@@ -505,12 +502,12 @@ class ServerManagerView {
}
openFunctionalTab(tabProps: FunctionalTabProps): void {
if (this.functionalTabs.has(tabProps.name)) {
this.activateTab(this.functionalTabs.get(tabProps.name));
if (this.functionalTabs[tabProps.name] !== undefined) {
this.activateTab(this.functionalTabs[tabProps.name]);
return;
}
this.functionalTabs.set(tabProps.name, this.tabs.length);
this.functionalTabs[tabProps.name] = this.tabs.length;
const tabIndex = this.getTabIndex();
@@ -519,28 +516,27 @@ class ServerManagerView {
materialIcon: tabProps.materialIcon,
name: tabProps.name,
$root: this.$tabsContainer,
index: this.functionalTabs.get(tabProps.name),
index: this.functionalTabs[tabProps.name],
tabIndex,
onClick: this.activateTab.bind(this, this.functionalTabs.get(tabProps.name)),
onDestroy: this.destroyTab.bind(this, tabProps.name, this.functionalTabs.get(tabProps.name)),
onClick: this.activateTab.bind(this, this.functionalTabs[tabProps.name]),
onDestroy: this.destroyTab.bind(this, tabProps.name, this.functionalTabs[tabProps.name]),
webview: new WebView({
$root: this.$webviewsContainer,
index: this.functionalTabs.get(tabProps.name),
index: this.functionalTabs[tabProps.name],
tabIndex,
url: tabProps.url,
role: 'function',
name: tabProps.name,
isActive: () => {
return this.functionalTabs.get(tabProps.name) === this.activeTabIndex;
return this.functionalTabs[tabProps.name] === this.activeTabIndex;
},
switchLoading: (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
switchLoading: (loading: AnyObject, url: string) => {
if (!loading && this.loading[url]) {
this.loading[url] = false;
} else if (loading && !this.loading[url]) {
this.loading[url] = true;
}
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
this.showLoading(this.loading[this.tabs[this.activeTabIndex].webview.props.url]);
},
onNetworkError: (index: number) => this.openNetworkTroubleshooting(index),
onTitleChange: this.updateBadge.bind(this),
@@ -553,17 +549,17 @@ class ServerManagerView {
// closed when the functional tab DOM is ready, handled in webview.js
this.$webviewsContainer.classList.remove('loaded');
this.activateTab(this.functionalTabs.get(tabProps.name));
this.activateTab(this.functionalTabs[tabProps.name]);
}
async openSettings(nav = 'General'): Promise<void> {
openSettings(nav = 'General'): void {
this.openFunctionalTab({
name: 'Settings',
materialIcon: 'settings',
url: `file://${rendererDirectory}/preference.html#${nav}`
});
this.$settingsButton.classList.add('active');
await this.tabs[this.functionalTabs.get('Settings')].webview.send('switch-settings-nav', nav);
this.tabs[this.functionalTabs.Settings].webview.send('switch-settings-nav', nav);
}
openAbout(): void {
@@ -588,7 +584,7 @@ class ServerManagerView {
ipcRenderer.send('save-last-tab', index);
}
// Returns this.tabs in an way that does
// returns this.tabs in an way that does
// not crash app when this.tabs is passed into
// ipcRenderer. Something about webview, and props.webview
// properties in ServerTab causes the app to crash.
@@ -598,7 +594,7 @@ class ServerManagerView {
const proto = Object.create(Object.getPrototypeOf(tab));
const tabClone = Object.assign(proto, tab);
tabClone.webview = {props: {}};
tabClone.webview = { props: {} };
tabClone.webview.props.name = tab.webview.props.name;
delete tabClone.props.webview;
tabs.push(tabClone);
@@ -615,27 +611,24 @@ class ServerManagerView {
if (this.activeTabIndex !== -1) {
if (this.activeTabIndex === index) {
return;
}
if (hideOldTab) {
} else if (hideOldTab) {
// If old tab is functional tab Settings, remove focus from the settings icon at sidebar bottom
if (this.tabs[this.activeTabIndex].props.role === 'function' && this.tabs[this.activeTabIndex].props.name === 'Settings') {
this.$settingsButton.classList.remove('active');
}
this.tabs[this.activeTabIndex].deactivate();
}
}
try {
this.tabs[index].webview.canGoBackButton();
} catch (_) {
} catch (err) {
}
this.activeTabIndex = index;
this.tabs[index].activate();
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
this.showLoading(this.loading[this.tabs[this.activeTabIndex].webview.props.url]);
ipcRenderer.send('update-menu', {
// JSON stringify this.tabs to avoid a crash
@@ -665,7 +658,7 @@ class ServerManagerView {
this.tabs[index].destroy();
delete this.tabs[index];
this.functionalTabs.delete(name);
delete this.functionalTabs[name];
// Issue #188: If the functional tab was not focused, do not activate another tab.
if (this.activeTabIndex === index) {
@@ -680,21 +673,21 @@ class ServerManagerView {
// Clear global variables
this.activeTabIndex = -1;
this.tabs = [];
this.functionalTabs.clear();
this.functionalTabs = {};
// Clear DOM elements
this.$tabsContainer.innerHTML = '';
this.$webviewsContainer.innerHTML = '';
}
async reloadView(): Promise<void> {
reloadView(): void {
// Save and remember the index of last active tab so that we can use it later
const lastActiveTab = this.tabs[this.activeTabIndex].props.index;
ConfigUtil.setConfigItem('lastActiveTab', lastActiveTab);
// Destroy the current view and re-initiate it
this.destroyView();
await this.initTabs();
this.initTabs();
this.initServerActions();
}
@@ -717,9 +710,11 @@ class ServerManagerView {
ipcRenderer.send('update-badge', messageCountAll);
}
updateGeneralSettings(setting: string, value: unknown): void {
if (this.getActiveWebview()) {
const webContents = this.getActiveWebview().getWebContents();
updateGeneralSettings(setting: string, value: any): void {
const selector = 'webview:not([class*=disabled])';
const webview: Electron.WebviewTag = document.querySelector(selector);
if (webview) {
const webContents = webview.getWebContents();
webContents.send(setting, value);
}
}
@@ -743,40 +738,35 @@ class ServerManagerView {
return !(url.endsWith('/login/') || this.tabs[tabIndex].webview.loading);
}
getActiveWebview(): Electron.WebviewTag {
const selector = 'webview:not(.disabled)';
const webview: Electron.WebviewTag = document.querySelector(selector);
return webview;
}
addContextMenu($serverImg: HTMLImageElement, index: number): void {
$serverImg.addEventListener('contextmenu', event => {
event.preventDefault();
$serverImg.addEventListener('contextmenu', e => {
e.preventDefault();
const template = [
{
label: 'Disconnect organization',
click: async () => {
const {response} = await dialog.showMessageBox({
click: () => {
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure you want to disconnect this organization?'
});
if (response === 0) {
if (DomainUtil.removeDomain(index)) {
ipcRenderer.send('reload-full-app');
} else {
const {title, content} = Messages.orgRemovalError(DomainUtil.getDomain(index).url);
dialog.showErrorBox(title, content);
}, response => {
if (response === 0) {
if (DomainUtil.removeDomain(index)) {
ipcRenderer.send('reload-full-app');
} else {
const { title, content } = Messages.orgRemovalError(DomainUtil.getDomain(index).url);
dialog.showErrorBox(title, content);
}
}
}
});
}
},
{
label: 'Notification settings',
enabled: this.isLoggedIn(index),
click: () => {
// Switch to tab whose icon was right-clicked
// switch to tab whose icon was right-clicked
this.activateTab(index);
this.tabs[index].webview.showNotificationSettings();
}
@@ -789,85 +779,47 @@ class ServerManagerView {
}
];
const contextMenu = Menu.buildFromTemplate(template);
contextMenu.popup({window: remote.getCurrentWindow()});
contextMenu.popup({ window: remote.getCurrentWindow() });
});
}
registerIpcs(): void {
const webviewListeners: Array<[string, (webview: WebView) => void]> = [
['webview-reload', webview => webview.reload()],
['back', webview => webview.back()],
['focus', webview => webview.focus()],
['forward', webview => webview.forward()],
['zoomIn', webview => webview.zoomIn()],
['zoomOut', webview => webview.zoomOut()],
['zoomActualSize', webview => webview.zoomActualSize()],
['log-out', webview => webview.logOut()],
['shortcut', webview => webview.showShortcut()],
['tab-devtools', webview => webview.openDevTools()]
];
const webviewListeners: AnyObject = {
'webview-reload': 'reload',
back: 'back',
focus: 'focus',
forward: 'forward',
zoomIn: 'zoomIn',
zoomOut: 'zoomOut',
zoomActualSize: 'zoomActualSize',
'log-out': 'logOut',
shortcut: 'showShortcut',
'tab-devtools': 'openDevTools'
};
for (const [channel, listener] of webviewListeners) {
ipcRenderer.on(channel, () => {
for (const key in webviewListeners) {
ipcRenderer.on(key, () => {
const activeWebview = this.tabs[this.activeTabIndex].webview;
if (activeWebview) {
listener(activeWebview);
activeWebview[webviewListeners[key] as string]();
}
});
}
ipcRenderer.on('certificate-error', (
event: Event,
webContentsId: number | null,
rendererCallbackId: number
) => {
const ignore = webContentsId !== null &&
this.tabs.some(
({webview}) =>
!webview.loading &&
webview.$el.getWebContentsId() === webContentsId &&
webview.props.ignoreCerts
);
ipcRenderer.send('renderer-callback', rendererCallbackId, ignore);
});
ipcRenderer.on('permission-request', (
event: Event,
{webContentsId, origin, permission}: {
webContentsId: number | null;
origin: string;
permission: string;
},
rendererCallbackId: number
) => {
const grant = webContentsId === null ?
origin === 'null' && permission === 'notifications' :
this.tabs.some(
({webview}) =>
!webview.loading &&
webview.$el.getWebContentsId() === webContentsId &&
webview.props.hasPermission?.(origin, permission)
);
console.log(
grant ? 'Granted' : 'Denied', 'permissions request for',
permission, 'from', origin
);
ipcRenderer.send('renderer-callback', rendererCallbackId, grant);
});
ipcRenderer.on('show-network-error', (event: Event, index: number) => {
this.openNetworkTroubleshooting(index);
});
ipcRenderer.on('open-settings', async (event: Event, settingNav: string) => {
await this.openSettings(settingNav);
ipcRenderer.on('open-settings', (event: Event, settingNav: string) => {
this.openSettings(settingNav);
});
ipcRenderer.on('open-about', this.openAbout.bind(this));
ipcRenderer.on('open-help', async () => {
ipcRenderer.on('open-help', () => {
// Open help page of current active server
await LinkUtil.openBrowser(new URL('https://zulipchat.com/help/'));
const helpPage = this.getCurrentActiveServer() + '/help';
shell.openExternal(helpPage);
});
ipcRenderer.on('reload-viewer', this.reloadView.bind(this, this.tabs[this.activeTabIndex].props.index));
@@ -886,19 +838,17 @@ class ServerManagerView {
this.activateLastTab(index);
});
ipcRenderer.on('open-org-tab', async () => {
await this.openSettings('AddServer');
ipcRenderer.on('open-org-tab', () => {
this.openSettings('AddServer');
});
ipcRenderer.on('reload-proxy', async (event: Event, showAlert: boolean) => {
await this.loadProxy();
if (showAlert) {
await dialog.showMessageBox({
message: 'Proxy settings saved!',
buttons: ['OK']
});
ipcRenderer.send('reload-full-app');
}
ipcRenderer.on('reload-proxy', (event: Event, showAlert: boolean) => {
this.loadProxy().then(() => {
if (showAlert) {
alert('Proxy settings saved!');
ipcRenderer.send('reload-full-app');
}
});
});
ipcRenderer.on('toggle-sidebar', (event: Event, show: boolean) => {
@@ -914,8 +864,8 @@ class ServerManagerView {
webviews.forEach(webview => {
try {
webview.setAudioMuted(state);
} catch (_) {
// Webview is not ready yet
} catch (err) {
// webview is not ready yet
webview.addEventListener('dom-ready', () => {
webview.setAudioMuted(state);
});
@@ -931,28 +881,31 @@ class ServerManagerView {
});
return;
}
this.updateGeneralSettings('toggle-menubar-setting', autoHideMenubar);
});
ipcRenderer.on('toggle-dnd', (event: Event, state: boolean, newSettings: DNDSettings) => {
ipcRenderer.on('toggle-dnd', (event: Event, state: boolean, newSettings: SettingsOptions) => {
this.toggleDNDButton(state);
ipcRenderer.send('forward-message', 'toggle-silent', newSettings.silent);
const webContents = this.getActiveWebview().getWebContents();
const selector = 'webview:not([class*=disabled])';
const webview: Electron.WebviewTag = document.querySelector(selector);
const webContents = webview.getWebContents();
webContents.send('toggle-dnd', state, newSettings);
});
ipcRenderer.on('update-realm-name', (event: Event, serverURL: string, realmName: string) => {
DomainUtil.getDomains().forEach((domain: DomainUtil.ServerConf, index: number) => {
// TODO: TypeScript - Type annotate getDomains() or this domain paramter.
DomainUtil.getDomains().forEach((domain: any, index: number) => {
if (domain.url.includes(serverURL)) {
const serverTooltipSelector = '.tab .server-tooltip';
const serverTooltipSelector = `.tab .server-tooltip`;
const serverTooltips = document.querySelectorAll(serverTooltipSelector);
serverTooltips[index].innerHTML = escape(realmName);
this.tabs[index].props.name = escape(realmName);
this.tabs[index].webview.props.name = realmName;
domain.alias = escape(realmName);
DomainUtil.updateDomain(index, domain);
DomainUtil.db.push(`/domains[${index}]`, domain, true);
DomainUtil.reloadDB();
// Update the realm name also on the Window menu
ipcRenderer.send('update-menu', {
tabs: this.tabsForIpc,
@@ -963,17 +916,18 @@ class ServerManagerView {
});
ipcRenderer.on('update-realm-icon', (event: Event, serverURL: string, iconURL: string) => {
DomainUtil.getDomains().forEach(async (domain, index) => {
// TODO: TypeScript - Type annotate getDomains() or this domain paramter.
DomainUtil.getDomains().forEach((domain: any, index: number) => {
if (domain.url.includes(serverURL)) {
const localIconUrl: string = await DomainUtil.saveServerIcon({
url: serverURL,
icon: iconURL
DomainUtil.saveServerIcon(iconURL).then((localIconUrl: string) => {
const serverImgsSelector = `.tab .server-icons`;
const serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(serverImgsSelector);
serverImgs[index].src = localIconUrl;
domain.icon = localIconUrl;
DomainUtil.db.push(`/domains[${index}]`, domain, true);
DomainUtil.reloadDB();
});
const serverImgsSelector = '.tab .server-icons';
const serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(serverImgsSelector);
serverImgs[index].src = localIconUrl;
domain.icon = localIconUrl;
DomainUtil.updateDomain(index, domain);
}
});
});
@@ -1023,10 +977,8 @@ class ServerManagerView {
ctx.font = '85px Helvetica';
ctx.fillText(String(Math.min(99, messageCount)), 64, 90);
}
return canvas;
}
ipcRenderer.send('update-taskbar-icon', createOverlayIcon(messageCount).toDataURL(), String(messageCount));
});
@@ -1038,46 +990,41 @@ class ServerManagerView {
clipboard.writeText(this.getCurrentActiveServer());
});
ipcRenderer.on('new-server', async () => {
await this.openSettings('AddServer');
ipcRenderer.on('new-server', () => {
this.openSettings('AddServer');
});
// Redo and undo functionality since the default API doesn't work on macOS
ipcRenderer.on('undo', () => {
return this.getActiveWebview().undo();
});
ipcRenderer.on('redo', () => {
return this.getActiveWebview().redo();
});
ipcRenderer.on('set-active', async () => {
ipcRenderer.on('set-active', () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
await Promise.all([...webviews].map(async webview => webview.send('set-active')));
webviews.forEach(webview => {
webview.send('set-active');
});
});
ipcRenderer.on('set-idle', async () => {
ipcRenderer.on('set-idle', () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
await Promise.all([...webviews].map(async webview => webview.send('set-idle')));
webviews.forEach(webview => {
webview.send('set-idle');
});
});
ipcRenderer.on('open-network-settings', async () => {
await this.openSettings('Network');
ipcRenderer.on('open-network-settings', () => {
this.openSettings('Network');
});
}
}
window.addEventListener('load', async () => {
// Only start electron-connect (auto reload on change) when its ran
window.addEventListener('load', () => {
const serverManagerView = new ServerManagerView();
serverManagerView.init();
// only start electron-connect (auto reload on change) when its ran
// from `npm run dev` or `gulp dev` and not from `npm start` when
// app is started `npm start` main process's proces.argv will have
// `--no-electron-connect`
if (isDev && !remote.getGlobal('process').argv.includes('--no-electron-connect')) {
const mainProcessArgv = remote.getGlobal('process').argv;
if (isDev && !mainProcessArgv.includes('--no-electron-connect')) {
require('electron-connect').client.create();
}
const serverManagerView = new ServerManagerView();
await serverManagerView.init();
});
export {};
export = new ServerManagerView();

View File

@@ -1,31 +1,34 @@
import {ipcRenderer} from 'electron';
'use strict';
import { ipcRenderer } from 'electron';
import {
appId, customReply, focusCurrentServer, parseReply
appId, customReply, focusCurrentServer, parseReply, setupReply
} from './helpers';
import MacNotifier from 'node-mac-notifier';
import * as ConfigUtil from '../utils/config-util';
import electron_bridge from '../electron-bridge';
import url = require('url');
import MacNotifier = require('node-mac-notifier');
import ConfigUtil = require('../utils/config-util');
type ReplyHandler = (response: string) => void;
type ClickHandler = () => void;
let replyHandler: ReplyHandler;
let clickHandler: ClickHandler;
declare const window: ZulipWebWindow;
interface NotificationHandlerArgs {
response: string;
}
class DarwinNotification {
tag: number;
tag: string;
constructor(title: string, options: NotificationOptions) {
constructor(title: string, opts: NotificationOptions) {
const silent: boolean = ConfigUtil.getConfigItem('silent') || false;
const {icon} = options;
const profilePic = new URL(icon, location.href).href;
const { host, protocol } = location;
const { icon } = opts;
const profilePic = url.resolve(`${protocol}//${host}`, icon);
this.tag = Number.parseInt(options.tag, 10);
const notification = new MacNotifier(title, Object.assign(options, {
this.tag = opts.tag;
const notification = new MacNotifier(title, Object.assign(opts, {
bundleId: appId,
canReply: true,
silent,
@@ -33,7 +36,7 @@ class DarwinNotification {
}));
notification.addEventListener('click', () => {
// Focus to the server who sent the
// focus to the server who sent the
// notification if not focused already
if (clickHandler) {
clickHandler();
@@ -55,23 +58,23 @@ class DarwinNotification {
return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied';
}
get onreply(): ReplyHandler {
return replyHandler;
}
set onreply(handler: ReplyHandler) {
replyHandler = handler;
}
get onclick(): ClickHandler {
return clickHandler;
get onreply(): ReplyHandler {
return replyHandler;
}
set onclick(handler: ClickHandler) {
clickHandler = handler;
}
// Not something that is common or
get onclick(): ClickHandler {
return clickHandler;
}
// not something that is common or
// used by zulip server but added to be
// future proff.
addEventListener(event: string, handler: ClickHandler | ReplyHandler): void {
@@ -84,15 +87,15 @@ class DarwinNotification {
}
}
async notificationHandler({response}: NotificationHandlerArgs): Promise<void> {
response = await parseReply(response);
notificationHandler({ response }: NotificationHandlerArgs): void {
response = parseReply(response);
focusCurrentServer();
if (electron_bridge.send_notification_reply_message_supported) {
electron_bridge.send_event('send_notification_reply_message', this.tag, response);
if (window.electron_bridge.send_notification_reply_message_supported) {
window.electron_bridge.send_event('send_notification_reply_message', this.tag, response);
return;
}
electron_bridge.emit('narrow-by-topic', this.tag);
setupReply(this.tag);
if (replyHandler) {
replyHandler(response);
return;
@@ -101,7 +104,7 @@ class DarwinNotification {
customReply(response);
}
// Method specific to notification api
// method specific to notification api
// used by zulip
close(): void {
return; // eslint-disable-line no-useless-return

View File

@@ -1,24 +1,26 @@
import {ipcRenderer} from 'electron';
import {focusCurrentServer} from './helpers';
'use strict';
import * as ConfigUtil from '../utils/config-util';
import { ipcRenderer } from 'electron';
import { focusCurrentServer } from './helpers';
import ConfigUtil = require('../utils/config-util');
const NativeNotification = window.Notification;
export default class BaseNotification extends NativeNotification {
constructor(title: string, options: NotificationOptions) {
options.silent = true;
super(title, options);
class BaseNotification extends NativeNotification {
constructor(title: string, opts: NotificationOptions) {
opts.silent = true;
super(title, opts);
this.addEventListener('click', () => {
// Focus to the server who sent the
// focus to the server who sent the
// notification if not focused already
focusCurrentServer();
ipcRenderer.send('focus-app');
});
}
static async requestPermission(): Promise<NotificationPermission> {
return this.permission;
static requestPermission(): void {
return; // eslint-disable-line no-useless-return
}
// Override default Notification permission
@@ -26,3 +28,5 @@ export default class BaseNotification extends NativeNotification {
return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied';
}
}
export = BaseNotification;

View File

@@ -1,6 +1,6 @@
import {remote} from 'electron';
import { remote } from 'electron';
import Logger from '../utils/logger-util';
import Logger = require('../utils/logger-util');
const logger = new Logger({
file: 'errors.log',
@@ -14,28 +14,37 @@ export type BotListItem = [string, string];
const botsList: BotListItem[] = [];
let botsListLoaded = false;
// This function load list of bots from the server
// this function load list of bots from the server
// sync=True for a synchronous getJSON request
// in case botsList isn't already completely loaded when required in parseRely
export async function loadBots(): Promise<void> {
export function loadBots(sync = false): void {
const { $ } = window;
botsList.length = 0;
const response = await fetch('/json/users');
if (response.ok) {
const {members} = await response.json();
members.forEach(({is_bot, full_name}: {[key: string]: unknown}) => {
if (is_bot && typeof full_name === 'string') {
const bot = `@${full_name}`;
const mention = `@**${bot.replace(/^@/, '')}**`;
botsList.push([bot, mention]);
}
if (sync) {
$.ajaxSetup({async: false});
}
$.getJSON('/json/users')
.done((data: any) => {
const { members } = data;
members.forEach((membersRow: any) => {
if (membersRow.is_bot) {
const bot = `@${membersRow.full_name}`;
const mention = `@**${bot.replace(/^@/, '')}**`;
botsList.push([bot, mention]);
}
});
botsListLoaded = true;
})
.fail((error: any) => {
logger.log('Load bots request failed: ', error.responseText);
logger.log('Load bots request status: ', error.statusText);
});
botsListLoaded = true;
} else {
logger.log('Load bots request failed: ', await response.text());
logger.log('Load bots request status: ', response.status);
if (sync) {
$.ajaxSetup({async: true});
}
}
export function checkElements(...elements: unknown[]): boolean {
export function checkElements(...elements: any[]): boolean {
let status = true;
elements.forEach(element => {
if (element === null || element === undefined) {
@@ -46,14 +55,14 @@ export function checkElements(...elements: unknown[]): boolean {
}
export function customReply(reply: string): void {
// Server does not support notification reply yet.
// server does not support notification reply yet.
const buttonSelector = '.messagebox #send_controls button[type=submit]';
const messageboxSelector = '.selected_message .messagebox .messagebox-border .messagebox-content';
const textarea: HTMLInputElement = document.querySelector('#compose-textarea');
const messagebox: HTMLButtonElement = document.querySelector(messageboxSelector);
const sendButton: HTMLButtonElement = document.querySelector(buttonSelector);
// Sanity check for old server versions
// sanity check for old server versions
const elementsExists = checkElements(textarea, messagebox, sendButton);
if (!elementsExists) {
return;
@@ -68,18 +77,19 @@ const currentWindow = remote.getCurrentWindow();
const webContents = remote.getCurrentWebContents();
const webContentsId = webContents.id;
// This function will focus the server that sent
// this function will focus the server that sent
// the notification. Main function implemented in main.js
export function focusCurrentServer(): void {
currentWindow.webContents.send('focus-webview-with-id', webContentsId);
// TODO: TypeScript: currentWindow of type BrowserWindow doesn't
// have a .send() property per typescript.
(currentWindow as any).send('focus-webview-with-id', webContentsId);
}
// This function parses the reply from to notification
// this function parses the reply from to notification
// making it easier to reply from notification eg
// @username in reply will be converted to @**username**
// #stream in reply will be converted to #**stream**
// bot mentions are not yet supported
export async function parseReply(reply: string): Promise<string> {
export function parseReply(reply: string): string {
const usersDiv = document.querySelectorAll('#user_presences li');
const streamHolder = document.querySelectorAll('#stream_filters li');
@@ -118,9 +128,9 @@ export async function parseReply(reply: string): Promise<string> {
reply = reply.replace(regex, streamMention);
});
// If botsList isn't completely loaded yet, make a request for list
if (!botsListLoaded) {
await loadBots();
// If botsList isn't completely loaded yet, make a synchronous getJSON request for list
if (botsListLoaded === false) {
loadBots(true);
}
// Iterate for every bot name and replace in reply
@@ -135,3 +145,9 @@ export async function parseReply(reply: string): Promise<string> {
reply = reply.replace(/\\n/, '\n');
return reply;
}
export function setupReply(id: string): void {
const { narrow } = window;
const narrowByTopic = narrow.by_topic || narrow.by_subject;
narrowByTopic(id, { trigger: 'notification' });
}

View File

@@ -1,73 +1,25 @@
import {remote} from 'electron';
import electron_bridge from '../electron-bridge';
import {appId, loadBots} from './helpers';
'use strict';
import DefaultNotification from './default-notification';
const {app} = remote;
import { remote } from 'electron';
import * as params from '../utils/params-util';
import { appId, loadBots } from './helpers';
import DefaultNotification = require('./default-notification');
const { app } = remote;
// From https://github.com/felixrieseberg/electron-windows-notifications#appusermodelid
// On windows 8 we have to explicitly set the appUserModelId otherwise notification won't work.
app.setAppUserModelId(appId);
let Notification = DefaultNotification;
window.Notification = DefaultNotification;
if (process.platform === 'darwin') {
Notification = require('./darwin-notifications');
window.Notification = require('./darwin-notifications');
}
export interface NotificationData {
close: () => void;
title: string;
dir: NotificationDirection;
lang: string;
body: string;
tag: string;
image: string;
icon: string;
badge: string;
vibrate: readonly number[];
timestamp: number;
renotify: boolean;
silent: boolean;
requireInteraction: boolean;
data: unknown;
actions: readonly NotificationAction[];
}
export function newNotification(
title: string,
options: NotificationOptions | undefined,
dispatch: (type: string, eventInit: EventInit) => boolean
): NotificationData {
const notification = new Notification(title, options);
for (const type of ['click', 'close', 'error', 'show']) {
notification.addEventListener(type, (ev: Event) => {
if (!dispatch(type, ev)) {
ev.preventDefault();
}
});
window.addEventListener('load', () => {
// eslint-disable-next-line no-undef, @typescript-eslint/camelcase
if (params.isPageParams() && page_params.realm_uri) {
loadBots();
}
return {
close: () => notification.close(),
title: notification.title,
dir: notification.dir,
lang: notification.lang,
body: notification.body,
tag: notification.tag,
image: notification.image,
icon: notification.icon,
badge: notification.badge,
vibrate: notification.vibrate,
timestamp: notification.timestamp,
renotify: notification.renotify,
silent: notification.silent,
requireInteraction: notification.requireInteraction,
data: notification.data,
actions: notification.actions
};
}
electron_bridge.once('zulip-loaded', async () => {
await loadBots();
});

View File

@@ -1,10 +1,16 @@
import {ipcRenderer} from 'electron';
'use strict';
export function init($reconnectButton: Element, $settingsButton: Element): void {
$reconnectButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'reload-viewer');
});
$settingsButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'open-settings');
});
import { ipcRenderer } from 'electron';
class NetworkTroubleshootingView {
init($reconnectButton: Element, $settingsButton: Element): void {
$reconnectButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'reload-viewer');
});
$settingsButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'open-settings');
});
}
}
export = new NetworkTroubleshootingView();

View File

@@ -1,26 +1,23 @@
'use-strict';
import {remote, OpenDialogOptions} from 'electron';
import { remote, OpenDialogOptions } from 'electron';
import path from 'path';
import BaseComponent from '../../components/base';
import * as CertificateUtil from '../../utils/certificate-util';
import * as DomainUtil from '../../utils/domain-util';
import * as t from '../../utils/translation-util';
import path = require('path');
import BaseComponent = require('../../components/base');
import CertificateUtil = require('../../utils/certificate-util');
import DomainUtil = require('../../utils/domain-util');
import t = require('../../utils/translation-util');
interface AddCertificateProps {
$root: Element;
}
const { dialog } = remote;
const {dialog} = remote;
export default class AddCertificate extends BaseComponent {
props: AddCertificateProps;
class AddCertificate extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
_certFile: string;
$addCertificate: Element | null;
addCertificateButton: Element | null;
serverUrl: HTMLInputElement | null;
constructor(props: AddCertificateProps) {
constructor(props: any) {
super();
this.props = props;
this._certFile = '';
@@ -49,7 +46,7 @@ export default class AddCertificate extends BaseComponent {
this.initListeners();
}
async validateAndAdd(): Promise<void> {
validateAndAdd(): void {
const certificate = this._certFile;
const serverUrl = this.serverUrl.value;
if (certificate !== '' && serverUrl !== '') {
@@ -59,41 +56,45 @@ export default class AddCertificate extends BaseComponent {
if (!copy) {
return;
}
CertificateUtil.setCertificate(server, fileName);
this.serverUrl.value = '';
await dialog.showMessageBox({
dialog.showMessageBox({
title: 'Success',
message: 'Certificate saved!'
message: `Certificate saved!`
});
this.serverUrl.value = '';
} else {
dialog.showErrorBox('Error', `Please, ${serverUrl === '' ?
'Enter an Organization URL' : 'Choose certificate file'}`);
}
}
async addHandler(): Promise<void> {
addHandler(): void {
const showDialogOptions: OpenDialogOptions = {
title: 'Select file',
properties: ['openFile'],
filters: [{name: 'crt, pem', extensions: ['crt', 'pem']}]
filters: [{ name: 'crt, pem', extensions: ['crt', 'pem'] }]
};
const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions);
if (!canceled) {
this._certFile = filePaths[0] || '';
await this.validateAndAdd();
}
dialog.showOpenDialog(showDialogOptions, selectedFile => {
if (selectedFile) {
this._certFile = selectedFile[0] || '';
this.validateAndAdd();
}
});
}
initListeners(): void {
this.addCertificateButton.addEventListener('click', async () => {
await this.addHandler();
this.addCertificateButton.addEventListener('click', () => {
this.addHandler();
});
this.serverUrl.addEventListener('keypress', async event => {
if (event.key === 'Enter') {
await this.addHandler();
this.serverUrl.addEventListener('keypress', event => {
const EnterkeyCode = event.keyCode;
if (EnterkeyCode === 13) {
this.addHandler();
}
});
}
}
export = AddCertificate;

View File

@@ -1,44 +1,63 @@
import electron, {app} from 'electron';
'use strict';
import { app } from 'electron';
import * as ConfigUtil from '../../utils/config-util';
import electron = require('electron');
import ConfigUtil = require('../../utils/config-util');
function showBadgeCount(messageCount: number, mainWindow: electron.BrowserWindow): void {
if (process.platform === 'win32') {
updateOverlayIcon(messageCount, mainWindow);
} else {
app.badgeCount = messageCount;
let instance: BadgeSettings | any = null;
class BadgeSettings {
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
return instance;
}
showBadgeCount(messageCount: number, mainWindow: electron.BrowserWindow): void {
if (process.platform === 'win32') {
this.updateOverlayIcon(messageCount, mainWindow);
} else {
// This should work on both macOS and Linux
app.setBadgeCount(messageCount);
}
}
hideBadgeCount(mainWindow: electron.BrowserWindow): void {
if (process.platform === 'darwin') {
app.setBadgeCount(0);
}
if (process.platform === 'win32') {
mainWindow.setOverlayIcon(null, '');
}
}
updateBadge(badgeCount: number, mainWindow: electron.BrowserWindow): void {
if (ConfigUtil.getConfigItem('badgeOption', true)) {
this.showBadgeCount(badgeCount, mainWindow);
} else {
this.hideBadgeCount(mainWindow);
}
}
updateOverlayIcon(messageCount: number, mainWindow: electron.BrowserWindow): void {
if (!mainWindow.isFocused()) {
mainWindow.flashFrame(ConfigUtil.getConfigItem('flashTaskbarOnMessage'));
}
if (messageCount === 0) {
mainWindow.setOverlayIcon(null, '');
} else {
mainWindow.webContents.send('render-taskbar-icon', messageCount);
}
}
updateTaskbarIcon(data: string, text: string, mainWindow: electron.BrowserWindow): void {
const img = electron.nativeImage.createFromDataURL(data);
mainWindow.setOverlayIcon(img, text);
}
}
function hideBadgeCount(mainWindow: electron.BrowserWindow): void {
if (process.platform === 'win32') {
mainWindow.setOverlayIcon(null, '');
} else {
app.badgeCount = 0;
}
}
export function updateBadge(badgeCount: number, mainWindow: electron.BrowserWindow): void {
if (ConfigUtil.getConfigItem('badgeOption', true)) {
showBadgeCount(badgeCount, mainWindow);
} else {
hideBadgeCount(mainWindow);
}
}
function updateOverlayIcon(messageCount: number, mainWindow: electron.BrowserWindow): void {
if (!mainWindow.isFocused()) {
mainWindow.flashFrame(ConfigUtil.getConfigItem('flashTaskbarOnMessage'));
}
if (messageCount === 0) {
mainWindow.setOverlayIcon(null, '');
} else {
mainWindow.webContents.send('render-taskbar-icon', messageCount);
}
}
export function updateTaskbarIcon(data: string, text: string, mainWindow: electron.BrowserWindow): void {
const img = electron.nativeImage.createFromDataURL(data);
mainWindow.setOverlayIcon(img, text);
}
export = new BadgeSettings();

View File

@@ -1,17 +1,12 @@
import {ipcRenderer} from 'electron';
import escape from 'escape-html';
'use strict';
import BaseComponent from '../../components/base';
import { ipcRenderer } from 'electron';
interface BaseSectionProps {
$element: HTMLElement;
disabled?: boolean;
value: boolean;
clickHandler: () => void;
}
import BaseComponent = require('../../components/base');
export default class BaseSection extends BaseComponent {
generateSettingOption(props: BaseSectionProps): void {
class BaseSection extends BaseComponent {
// TODO: TypeScript - Here props should be object type
generateSettingOption(props: any): void {
const {$element, disabled, value, clickHandler} = props;
$element.innerHTML = '';
@@ -24,8 +19,8 @@ export default class BaseSection extends BaseComponent {
}
}
generateOptionTemplate(settingOption: boolean, disabled?: boolean): string {
const label = disabled ? '<label class="disallowed" title="Setting locked by system administrator."/>' : '<label/>';
generateOptionTemplate(settingOption: boolean, disabled: boolean): string {
const label = disabled ? `<label class="disallowed" title="Setting locked by system administrator."/>` : `<label/>`;
if (settingOption) {
return `
<div class="action">
@@ -35,9 +30,8 @@ export default class BaseSection extends BaseComponent {
</div>
</div>
`;
}
return `
} else {
return `
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox">
@@ -45,21 +39,12 @@ export default class BaseSection extends BaseComponent {
</div>
</div>
`;
}
/* A method that in future can be used to create dropdown menus using <select> <option> tags.
it needs an object which has ``key: value`` pairs and will return a string that can be appended to HTML
*/
generateSelectTemplate(options: {[key: string]: string}, className?: string, idName?: string): string {
let select = `<select class="${escape(className)}" id="${escape(idName)}">\n`;
Object.keys(options).forEach(key => {
select += `<option name="${escape(key)}" value="${escape(key)}">${escape(options[key])}</option>\n`;
});
select += '</select>';
return select;
}
}
reloadApp(): void {
ipcRenderer.send('forward-message', 'reload-viewer');
}
}
export = BaseSection;

View File

@@ -1,24 +1,23 @@
import {ipcRenderer} from 'electron';
'use strict';
import BaseSection from './base-section';
import * as DomainUtil from '../../utils/domain-util';
import ServerInfoForm from './server-info-form';
import AddCertificate from './add-certificate';
import FindAccounts from './find-accounts';
import * as t from '../../utils/translation-util';
import { ipcRenderer } from 'electron';
interface ConnectedOrgSectionProps {
$root: Element;
}
import BaseSection = require('./base-section');
import DomainUtil = require('../../utils/domain-util');
import ServerInfoForm = require('./server-info-form');
import AddCertificate = require('./add-certificate');
import FindAccounts = require('./find-accounts');
import t = require('../../utils/translation-util');
export default class ConnectedOrgSection extends BaseSection {
props: ConnectedOrgSectionProps;
class ConnectedOrgSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
$serverInfoContainer: Element | null;
$existingServers: Element | null;
$newOrgButton: HTMLButtonElement | null;
$addCertificateContainer: Element | null;
$findAccountsContainer: Element | null;
constructor(props: ConnectedOrgSectionProps) {
constructor(props: any) {
super();
this.props = props;
}
@@ -58,10 +57,10 @@ export default class ConnectedOrgSection extends BaseSection {
// Show noServerText if no servers are there otherwise hide it
this.$existingServers.innerHTML = servers.length === 0 ? noServerText : '';
for (const [i, server] of servers.entries()) {
for (let i = 0; i < servers.length; i++) {
new ServerInfoForm({
$root: this.$serverInfoContainer,
server,
server: servers[i],
index: i,
onChange: this.reloadApp
}).init();
@@ -87,3 +86,5 @@ export default class ConnectedOrgSection extends BaseSection {
}).init();
}
}
export = ConnectedOrgSection;

View File

@@ -1,19 +1,17 @@
'use-strict';
import BaseComponent from '../../components/base';
import * as LinkUtil from '../../utils/link-util';
import * as t from '../../utils/translation-util';
import { shell } from 'electron';
interface FindAccountsProps {
$root: Element;
}
import BaseComponent = require('../../components/base');
import t = require('../../utils/translation-util');
export default class FindAccounts extends BaseComponent {
props: FindAccountsProps;
class FindAccounts extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
$findAccounts: Element | null;
$findAccountsButton: Element | null;
$serverUrlField: HTMLInputElement | null;
constructor(props: FindAccountsProps) {
constructor(props: any) {
super();
this.props = props;
}
@@ -40,21 +38,19 @@ export default class FindAccounts extends BaseComponent {
this.initListeners();
}
async findAccounts(url: string): Promise<void> {
findAccounts(url: string): void {
if (!url) {
return;
}
if (!url.startsWith('http')) {
url = 'https://' + url;
}
await LinkUtil.openBrowser(new URL('/accounts/find', url));
shell.openExternal(url + '/accounts/find');
}
initListeners(): void {
this.$findAccountsButton.addEventListener('click', async () => {
await this.findAccounts(this.$serverUrlField.value);
this.$findAccountsButton.addEventListener('click', () => {
this.findAccounts(this.$serverUrlField.value);
});
this.$serverUrlField.addEventListener('click', () => {
@@ -63,9 +59,9 @@ export default class FindAccounts extends BaseComponent {
}
});
this.$serverUrlField.addEventListener('keypress', async event => {
if (event.key === 'Enter') {
await this.findAccounts(this.$serverUrlField.value);
this.$serverUrlField.addEventListener('keypress', event => {
if (event.keyCode === 13) {
this.findAccounts(this.$serverUrlField.value);
}
});
@@ -78,3 +74,5 @@ export default class FindAccounts extends BaseComponent {
});
}
}
export = FindAccounts;

View File

@@ -1,24 +1,21 @@
import {ipcRenderer, remote, OpenDialogOptions} from 'electron';
'use strict';
import { ipcRenderer, remote, OpenDialogOptions } from 'electron';
import path from 'path';
import fs from 'fs-extra';
import path = require('path');
import fs = require('fs-extra');
const {app, dialog} = remote;
const { app, dialog } = remote;
const currentBrowserWindow = remote.getCurrentWindow();
import BaseSection from './base-section';
import * as ConfigUtil from '../../utils/config-util';
import * as EnterpriseUtil from '../../utils/enterprise-util';
import * as t from '../../utils/translation-util';
import supportedLocales from '../../../../translations/supported-locales.json';
import BaseSection = require('./base-section');
import ConfigUtil = require('../../utils/config-util');
import EnterpriseUtil = require('./../../utils/enterprise-util');
import t = require('../../utils/translation-util');
interface GeneralSectionProps {
$root: Element;
}
export default class GeneralSection extends BaseSection {
props: GeneralSectionProps;
constructor(props: GeneralSectionProps) {
class GeneralSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
constructor(props: any) {
super();
this.props = props;
}
@@ -96,32 +93,29 @@ export default class GeneralSection extends BaseSection {
</div>
<div class="title">${t.__('Advanced')}</div>
<div class="settings-card">
<div class="setting-row" id="enable-error-reporting">
<div class="setting-description">${t.__('Enable error reporting (requires restart)')}</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="app-language">
<div class="setting-description">${t.__('App language (requires restart)')}</div>
<div id="lang-div" class="lang-div"></div>
</div>
<div class="setting-row" id="add-custom-css">
<div class="setting-description">
${t.__('Add custom CSS')}
</div>
<button class="custom-css-button green">${t.__('Upload')}</button>
</div>
<div class="setting-row" id="remove-custom-css">
<div class="setting-description">
<div class="selected-css-path" id="custom-css-path">${ConfigUtil.getConfigString('customCSS', '')}</div>
</div>
<div class="action red" id="css-delete-action">
<i class="material-icons">indeterminate_check_box</i>
<span>${t.__('Delete')}</span>
</div>
</div>
<div class="setting-row" id="enable-error-reporting">
<div class="setting-description">${t.__('Enable error reporting (requires restart)')}</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="show-download-folder">
<div class="setting-description">${t.__('Show downloaded files in file manager')}</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="add-custom-css">
<div class="setting-description">
${t.__('Add custom CSS')}
</div>
<button class="custom-css-button green">${t.__('Upload')}</button>
</div>
<div class="setting-row" id="remove-custom-css">
<div class="setting-description">
<div class="selected-css-path" id="custom-css-path">${ConfigUtil.getConfigItem('customCSS')}</div>
</div>
<div class="action red" id="css-delete-action">
<i class="material-icons">indeterminate_check_box</i>
<span>${t.__('Delete')}</span>
</div>
</div>
<div class="setting-row" id="download-folder">
<div class="setting-description">
${t.__('Default download location')}
@@ -130,13 +124,10 @@ export default class GeneralSection extends BaseSection {
</div>
<div class="setting-row">
<div class="setting-description">
<div class="download-folder-path">${ConfigUtil.getConfigString('downloadsPath', app.getPath('downloads'))}</div>
<div class="download-folder-path">${ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`)}</div>
</div>
</div>
<div class="setting-row" id="prompt-download">
<div class="setting-description">${t.__('Ask where to save files before downloading')}</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">${t.__('Reset Application Data')}</div>
<div class="settings-card">
@@ -146,7 +137,7 @@ export default class GeneralSection extends BaseSection {
<button class="reset-data-button red w-150">${t.__('Reset App Data')}</button>
</div>
</div>
</div>
</div>
`;
}
@@ -167,10 +158,9 @@ export default class GeneralSection extends BaseSection {
this.showCustomCSSPath();
this.removeCustomCSS();
this.downloadFolder();
this.showDownloadFolder();
this.updateQuitOnCloseOption();
this.updatePromptDownloadOption();
this.enableErrorReporting();
this.setLocale();
// Platform specific settings
@@ -178,7 +168,6 @@ export default class GeneralSection extends BaseSection {
if (process.platform === 'win32') {
this.updateFlashTaskbar();
}
// Dock bounce on macOS
if (process.platform === 'darwin') {
this.updateDockBouncing();
@@ -265,7 +254,6 @@ export default class GeneralSection extends BaseSection {
ConfigUtil.setConfigItem('betaUpdate', false);
this.betaUpdateOption();
}
this.autoUpdateOption();
}
});
@@ -293,7 +281,9 @@ export default class GeneralSection extends BaseSection {
const newValue = !ConfigUtil.getConfigItem('silent', true);
ConfigUtil.setConfigItem('silent', newValue);
this.updateSilentOption();
currentBrowserWindow.webContents.send('toggle-silent', newValue);
// TODO: TypeScript: currentWindow of type BrowserWindow doesn't
// have a .send() property per typescript.
(currentBrowserWindow as any).send('toggle-silent', newValue);
}
});
}
@@ -372,59 +362,43 @@ export default class GeneralSection extends BaseSection {
});
}
async clearAppDataDialog(): Promise<void> {
clearAppDataDialog(): void {
const clearAppDataMessage = 'By clicking proceed you will be removing all added accounts and preferences from Zulip. When the application restarts, it will be as if you are starting Zulip for the first time.';
const getAppPath = path.join(app.getPath('appData'), app.name);
const getAppPath = path.join(app.getPath('appData'), app.getName());
const {response} = await dialog.showMessageBox({
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure',
detail: clearAppDataMessage
}, response => {
if (response === 0) {
fs.remove(getAppPath);
setTimeout(() => ipcRenderer.send('forward-message', 'hard-reload'), 1000);
}
});
if (response === 0) {
await fs.remove(getAppPath);
ipcRenderer.send('forward-message', 'hard-reload');
}
}
async customCssDialog(): Promise<void> {
customCssDialog(): void {
const showDialogOptions: OpenDialogOptions = {
title: 'Select file',
properties: ['openFile'],
filters: [{name: 'CSS file', extensions: ['css']}]
filters: [{ name: 'CSS file', extensions: ['css'] }]
};
const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions);
if (!canceled) {
ConfigUtil.setConfigItem('customCSS', filePaths[0]);
ipcRenderer.send('forward-message', 'hard-reload');
}
dialog.showOpenDialog(showDialogOptions, selectedFile => {
if (selectedFile) {
ConfigUtil.setConfigItem('customCSS', selectedFile[0]);
ipcRenderer.send('forward-message', 'hard-reload');
}
});
}
updateResetDataOption(): void {
const resetDataButton = document.querySelector('#resetdata-option .reset-data-button');
resetDataButton.addEventListener('click', async () => {
await this.clearAppDataDialog();
});
}
setLocale(): void {
const langDiv: HTMLSelectElement = document.querySelector('.lang-div');
// This path is for the JSON file that stores key: value pairs for supported locales
const langList = this.generateSelectTemplate(supportedLocales, 'lang-menu');
langDiv.innerHTML += langList;
// `langMenu` is the select-option dropdown menu formed after executing the previous command
const langMenu: HTMLSelectElement = document.querySelector('.lang-menu');
// The next three lines set the selected language visible on the dropdown button
let language = ConfigUtil.getConfigItem('appLanguage');
language = language && langMenu.options.namedItem(language) ? language : 'en-US';
langMenu.options.namedItem(language).selected = true;
langMenu.addEventListener('change', (event: Event) => {
ConfigUtil.setConfigItem('appLanguage', (event.target as HTMLSelectElement).value);
resetDataButton.addEventListener('click', () => {
this.clearAppDataDialog();
});
}
@@ -442,8 +416,8 @@ export default class GeneralSection extends BaseSection {
addCustomCSS(): void {
const customCSSButton = document.querySelector('#add-custom-css .custom-css-button');
customCSSButton.addEventListener('click', async () => {
await this.customCssDialog();
customCSSButton.addEventListener('click', () => {
this.customCssDialog();
});
}
@@ -457,41 +431,43 @@ export default class GeneralSection extends BaseSection {
removeCustomCSS(): void {
const removeCSSButton = document.querySelector('#css-delete-action');
removeCSSButton.addEventListener('click', () => {
ConfigUtil.setConfigItem('customCSS', '');
ConfigUtil.setConfigItem('customCSS', "");
ipcRenderer.send('forward-message', 'hard-reload');
});
}
async downloadFolderDialog(): Promise<void> {
downloadFolderDialog(): void {
const showDialogOptions: OpenDialogOptions = {
title: 'Select Download Location',
properties: ['openDirectory']
};
const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions);
if (!canceled) {
ConfigUtil.setConfigItem('downloadsPath', filePaths[0]);
const downloadFolderPath: HTMLElement = document.querySelector('.download-folder-path');
downloadFolderPath.textContent = filePaths[0];
}
dialog.showOpenDialog(showDialogOptions, selectedFolder => {
if (selectedFolder) {
ConfigUtil.setConfigItem('downloadsPath', selectedFolder[0]);
const downloadFolderPath: HTMLElement = document.querySelector('.download-folder-path');
downloadFolderPath.innerText = selectedFolder[0];
}
});
}
downloadFolder(): void {
const downloadFolder = document.querySelector('#download-folder .download-folder-button');
downloadFolder.addEventListener('click', async () => {
await this.downloadFolderDialog();
downloadFolder.addEventListener('click', () => {
this.downloadFolderDialog();
});
}
updatePromptDownloadOption(): void {
showDownloadFolder(): void {
this.generateSettingOption({
$element: document.querySelector('#prompt-download .setting-control'),
value: ConfigUtil.getConfigItem('promptDownload', false),
$element: document.querySelector('#show-download-folder .setting-control'),
value: ConfigUtil.getConfigItem('showDownloadFolder', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('promptDownload');
ConfigUtil.setConfigItem('promptDownload', newValue);
this.updatePromptDownloadOption();
const newValue = !ConfigUtil.getConfigItem('showDownloadFolder');
ConfigUtil.setConfigItem('showDownloadFolder', newValue);
this.showDownloadFolder();
}
});
}
}
export = GeneralSection;

View File

@@ -1,16 +1,14 @@
import BaseComponent from '../../components/base';
import * as t from '../../utils/translation-util';
'use strict';
interface PreferenceNavProps {
$root: Element;
onItemSelected: (navItem: string) => void;
}
import BaseComponent = require('../../components/base');
import t = require('../../utils/translation-util');
export default class PreferenceNav extends BaseComponent {
props: PreferenceNavProps;
class PreferenceNav extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
navItems: string[];
$el: Element;
constructor(props: PreferenceNavProps) {
constructor(props: any) {
super();
this.props = props;
this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts'];
@@ -66,3 +64,5 @@ export default class PreferenceNav extends BaseComponent {
$item.classList.remove('active');
}
}
export = PreferenceNav;

View File

@@ -1,21 +1,20 @@
import {ipcRenderer} from 'electron';
'use strict';
import BaseSection from './base-section';
import * as ConfigUtil from '../../utils/config-util';
import * as t from '../../utils/translation-util';
import { ipcRenderer } from 'electron';
interface NetworkSectionProps {
$root: Element;
}
import BaseSection = require('./base-section');
import ConfigUtil = require('../../utils/config-util');
import t = require('../../utils/translation-util');
export default class NetworkSection extends BaseSection {
props: NetworkSectionProps;
class NetworkSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
$proxyPAC: HTMLInputElement;
$proxyRules: HTMLInputElement;
$proxyBypass: HTMLInputElement;
$proxySaveAction: Element;
$manualProxyBlock: Element;
constructor(props: NetworkSectionProps) {
constructor(props: any) {
super();
this.props = props;
}
@@ -105,13 +104,11 @@ export default class NetworkSection extends BaseSection {
ConfigUtil.setConfigItem('useManualProxy', !manualProxyValue);
this.toggleManualProxySettings(!manualProxyValue);
}
if (!newValue) {
if (newValue === false) {
// Remove proxy system proxy settings
ConfigUtil.setConfigItem('proxyRules', '');
ipcRenderer.send('forward-message', 'reload-proxy', false);
}
ConfigUtil.setConfigItem('useSystemProxy', newValue);
this.updateProxyOption();
}
@@ -126,7 +123,6 @@ export default class NetworkSection extends BaseSection {
if (systemProxyValue && newValue) {
ConfigUtil.setConfigItem('useSystemProxy', !systemProxyValue);
}
ConfigUtil.setConfigItem('proxyRules', '');
ConfigUtil.setConfigItem('useManualProxy', newValue);
// Reload app only when turning manual proxy off, hence !newValue
@@ -136,3 +132,5 @@ export default class NetworkSection extends BaseSection {
});
}
}
export = NetworkSection;

View File

@@ -1,23 +1,18 @@
import {ipcRenderer, remote} from 'electron';
'use strict';
import BaseComponent from '../../components/base';
import * as DomainUtil from '../../utils/domain-util';
import * as LinkUtil from '../../utils/link-util';
import * as t from '../../utils/translation-util';
import { shell, ipcRenderer } from 'electron';
const {dialog} = remote;
import BaseComponent = require('../../components/base');
import DomainUtil = require('../../utils/domain-util');
import t = require('../../utils/translation-util');
interface NewServerFormProps {
$root: Element;
onChange: () => void;
}
export default class NewServerForm extends BaseComponent {
props: NewServerFormProps;
class NewServerForm extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
$newServerForm: Element;
$saveServerButton: HTMLButtonElement;
$newServerUrl: HTMLInputElement;
constructor(props: NewServerFormProps) {
constructor(props: any) {
super();
this.props = props;
}
@@ -30,7 +25,9 @@ export default class NewServerForm extends BaseComponent {
<input class="setting-input-value" autofocus placeholder="your-organization.zulipchat.com or zulip.your-organization.com"/>
</div>
<div class="server-center">
<button id="connect">${t.__('Connect')}</button>
<div class="server-save-action">
<button id="connect">${t.__('Connect')}</button>
</div>
</div>
<div class="server-center">
<div class="divider">
@@ -38,7 +35,9 @@ export default class NewServerForm extends BaseComponent {
</div>
</div>
<div class="server-center">
<button id="open-create-org-link">${t.__('Create a new organization')}</button>
<div class="server-save-action">
<button id="open-create-org-link">${t.__('Create a new organization')}</button>
</div>
</div>
<div class="server-center">
<div class="server-network-option">
@@ -57,36 +56,29 @@ export default class NewServerForm extends BaseComponent {
initForm(): void {
this.$newServerForm = this.generateNodeFromTemplate(this.template());
this.$saveServerButton = this.$newServerForm.querySelector('#connect');
this.$saveServerButton = this.$newServerForm.querySelectorAll('.server-save-action')[0] as HTMLButtonElement;
this.props.$root.innerHTML = '';
this.props.$root.append(this.$newServerForm);
this.$newServerUrl = this.$newServerForm.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement;
}
async submitFormHandler(): Promise<void> {
this.$saveServerButton.innerHTML = 'Connecting...';
let serverConf;
try {
serverConf = await DomainUtil.checkDomain(this.$newServerUrl.value);
} catch (error) {
this.$saveServerButton.innerHTML = 'Connect';
await dialog.showMessageBox({
type: 'error',
message: error.toString(),
buttons: ['OK']
submitFormHandler(): void {
this.$saveServerButton.children[0].innerHTML = 'Connecting...';
DomainUtil.checkDomain(this.$newServerUrl.value).then(serverConf => {
DomainUtil.addDomain(serverConf).then(() => {
this.props.onChange(this.props.index);
});
return;
}
await DomainUtil.addDomain(serverConf);
this.props.onChange();
}, errorMessage => {
this.$saveServerButton.children[0].innerHTML = 'Connect';
alert(errorMessage);
});
}
openCreateNewOrgExternalLink(): void {
const link = 'https://zulipchat.com/new/';
const externalCreateNewOrgElement = document.querySelector('#open-create-org-link');
externalCreateNewOrgElement.addEventListener('click', async () => {
await LinkUtil.openBrowser(new URL(link));
const externalCreateNewOrgEl = document.querySelector('#open-create-org-link');
externalCreateNewOrgEl.addEventListener('click', () => {
shell.openExternal(link);
});
}
@@ -96,16 +88,20 @@ export default class NewServerForm extends BaseComponent {
}
initActions(): void {
this.$saveServerButton.addEventListener('click', async () => {
await this.submitFormHandler();
this.$saveServerButton.addEventListener('click', () => {
this.submitFormHandler();
});
this.$newServerUrl.addEventListener('keypress', async event => {
if (event.key === 'Enter') {
await this.submitFormHandler();
this.$newServerUrl.addEventListener('keypress', event => {
const EnterkeyCode = event.keyCode;
// Submit form when Enter key is pressed
if (EnterkeyCode === 13) {
this.submitFormHandler();
}
});
// Open create new org link in default browser
// open create new org link in default browser
this.openCreateNewOrgExternalLink();
this.networkSettingsLink();
}
}
export = NewServerForm;

View File

@@ -1,17 +1,18 @@
import {ipcRenderer} from 'electron';
'use strict';
import BaseComponent from '../../components/base';
import Nav from './nav';
import ServersSection from './servers-section';
import GeneralSection from './general-section';
import NetworkSection from './network-section';
import ConnectedOrgSection from './connected-org-section';
import ShortcutsSection from './shortcuts-section';
import type {DNDSettings} from '../../utils/dnd-util';
import { ipcRenderer } from 'electron';
import BaseComponent = require('../../components/base');
import Nav = require('./nav');
import ServersSection = require('./servers-section');
import GeneralSection = require('./general-section');
import NetworkSection = require('./network-section');
import ConnectedOrgSection = require('./connected-org-section');
import ShortcutsSection = require('./shortcuts-section');
type Section = ServersSection | GeneralSection | NetworkSection | ConnectedOrgSection | ShortcutsSection;
export default class PreferenceView extends BaseComponent {
class PreferenceView extends BaseComponent {
$sidebarContainer: Element;
$settingsContainer: Element;
nav: Nav;
@@ -36,9 +37,8 @@ export default class PreferenceView extends BaseComponent {
let nav = 'General';
const hasTag = window.location.hash;
if (hasTag) {
nav = hasTag.slice(1);
nav = hasTag.substring(1);
}
this.handleNavigation(nav);
}
@@ -51,38 +51,32 @@ export default class PreferenceView extends BaseComponent {
});
break;
}
case 'General': {
this.section = new GeneralSection({
$root: this.$settingsContainer
});
break;
}
case 'Organizations': {
this.section = new ConnectedOrgSection({
$root: this.$settingsContainer
});
break;
}
case 'Network': {
this.section = new NetworkSection({
$root: this.$settingsContainer
});
break;
}
case 'Shortcuts': {
this.section = new ShortcutsSection({
$root: this.$settingsContainer
});
break;
}
default: break;
}
this.section.init();
window.location.hash = `#${navItem}`;
}
@@ -113,7 +107,7 @@ export default class PreferenceView extends BaseComponent {
this.handleToggle('tray-option', state);
});
ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: DNDSettings) => {
ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: any) => {
this.handleToggle('show-notification-option', newSettings.showNotification);
this.handleToggle('silent-option', newSettings.silent);
@@ -128,3 +122,5 @@ window.addEventListener('load', () => {
const preferenceView = new PreferenceView();
preferenceView.init();
});
export = PreferenceView;

View File

@@ -1,27 +1,23 @@
import {remote, ipcRenderer} from 'electron';
'use strict';
import BaseComponent from '../../components/base';
import * as DomainUtil from '../../utils/domain-util';
import * as Messages from '../../../../resources/messages';
import * as t from '../../utils/translation-util';
import { remote, ipcRenderer } from 'electron';
const {dialog} = remote;
import BaseComponent = require('../../components/base');
import DomainUtil = require('../../utils/domain-util');
import Messages = require('./../../../../resources/messages');
import t = require('../../utils/translation-util');
interface ServerInfoFormProps {
$root: Element;
server: DomainUtil.ServerConf;
index: number;
onChange: () => void;
}
const { dialog } = remote;
export default class ServerInfoForm extends BaseComponent {
props: ServerInfoFormProps;
class ServerInfoForm extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
$serverInfoForm: Element;
$serverInfoAlias: Element;
$serverIcon: Element;
$deleteServerButton: Element;
$openServerButton: Element;
constructor(props: ServerInfoFormProps) {
constructor(props: any) {
super();
this.props = props;
}
@@ -65,21 +61,22 @@ export default class ServerInfoForm extends BaseComponent {
}
initActions(): void {
this.$deleteServerButton.addEventListener('click', async () => {
const {response} = await dialog.showMessageBox({
this.$deleteServerButton.addEventListener('click', () => {
dialog.showMessageBox({
type: 'warning',
buttons: [t.__('YES'), t.__('NO')],
defaultId: 0,
message: t.__('Are you sure you want to disconnect this organization?')
});
if (response === 0) {
if (DomainUtil.removeDomain(this.props.index)) {
ipcRenderer.send('reload-full-app');
} else {
const {title, content} = Messages.orgRemovalError(DomainUtil.getDomain(this.props.index).url);
dialog.showErrorBox(title, content);
}, response => {
if (response === 0) {
if (DomainUtil.removeDomain(this.props.index)) {
ipcRenderer.send('reload-full-app');
} else {
const { title, content } = Messages.orgRemovalError(DomainUtil.getDomain(this.props.index).url);
dialog.showErrorBox(title, content);
}
}
}
});
});
this.$openServerButton.addEventListener('click', () => {
@@ -95,3 +92,5 @@ export default class ServerInfoForm extends BaseComponent {
});
}
}
export = ServerInfoForm;

View File

@@ -1,15 +1,14 @@
import BaseSection from './base-section';
import NewServerForm from './new-server-form';
import * as t from '../../utils/translation-util';
'use strict';
interface ServersSectionProps {
$root: Element;
}
import BaseSection = require('./base-section');
import NewServerForm = require('./new-server-form');
import t = require('../../utils/translation-util');
export default class ServersSection extends BaseSection {
props: ServersSectionProps;
class ServersSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
$newServerContainer: Element;
constructor(props: ServersSectionProps) {
constructor(props: any) {
super();
this.props = props;
}
@@ -47,3 +46,5 @@ export default class ServersSection extends BaseSection {
}).init();
}
}
export = ServersSection;

View File

@@ -1,18 +1,17 @@
import BaseSection from './base-section';
import * as LinkUtil from '../../utils/link-util';
import * as t from '../../utils/translation-util';
'use strict';
interface ShortcutsSectionProps {
$root: Element;
}
import { shell } from 'electron';
export default class ShortcutsSection extends BaseSection {
props: ShortcutsSectionProps;
constructor(props: ShortcutsSectionProps) {
import BaseSection = require('./base-section');
import t = require('../../utils/translation-util');
class ShortcutsSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
constructor(props: any) {
super();
this.props = props;
}
// TODO - Deduplicate templateMac and templateWinLin functions. In theory
// they both should be the same the only thing different should be the userOSKey
// variable but there seems to be inconsistences between both function, one has more
@@ -331,15 +330,16 @@ export default class ShortcutsSection extends BaseSection {
openHotkeysExternalLink(): void {
const link = 'https://zulipchat.com/help/keyboard-shortcuts';
const externalCreateNewOrgElement = document.querySelector('#open-hotkeys-link');
externalCreateNewOrgElement.addEventListener('click', async () => {
await LinkUtil.openBrowser(new URL(link));
const externalCreateNewOrgEl = document.querySelector('#open-hotkeys-link');
externalCreateNewOrgEl.addEventListener('click', () => {
shell.openExternal(link);
});
}
init(): void {
this.props.$root.innerHTML = (process.platform === 'darwin') ?
this.templateMac() : this.templateWinLin();
this.openHotkeysExternalLink();
}
}
export = ShortcutsSection;

View File

@@ -1,31 +1,47 @@
import {contextBridge, ipcRenderer, webFrame} from 'electron';
import fs from 'fs';
import * as SetupSpellChecker from './spellchecker';
// we have and will have some non camelcase stuff
// while working with zulip and electron bridge
// so turning the rule off for the whole file.
/* eslint-disable @typescript-eslint/camelcase */
import isDev from 'electron-is-dev';
'use strict';
import * as NetworkError from './pages/network';
import { ipcRenderer, shell } from 'electron';
import SetupSpellChecker from './spellchecker';
import isDev = require('electron-is-dev');
import LinkUtil = require('./utils/link-util');
import params = require('./utils/params-util');
import NetworkError = require('./pages/network');
interface PatchedGlobal extends NodeJS.Global {
logout: () => void;
shortcut: () => void;
showNotificationSettings: () => void;
}
const globalPatched = global as PatchedGlobal;
// eslint-disable-next-line import/no-unassigned-import
import './notification';
require('./notification');
// Prevent drag and drop event in main process which prevents remote code executaion
// eslint-disable-next-line import/no-unassigned-import
import './shared/preventdrag';
require(__dirname + '/shared/preventdrag.js');
import electron_bridge from './electron-bridge';
contextBridge.exposeInMainWorld('raw_electron_bridge', electron_bridge);
declare let window: ZulipWebWindow;
ipcRenderer.on('logout', () => {
window.electron_bridge = require('./electron-bridge');
const logout = (): void => {
// Create the menu for the below
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle');
dropdown.click();
const nodes: NodeListOf<HTMLElement> = document.querySelectorAll('.dropdown-menu li:last-child a');
nodes[nodes.length - 1].click();
});
};
ipcRenderer.on('shortcut', () => {
const shortcut = (): void => {
// Create the menu for the below
const node: HTMLElement = document.querySelector('a[data-overlay-trigger=keyboard-shortcuts]');
// Additional check
@@ -36,9 +52,9 @@ ipcRenderer.on('shortcut', () => {
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle');
dropdown.click();
}
});
};
ipcRenderer.on('show-notification-settings', () => {
const showNotificationSettings = (): void => {
// Create the menu for the below
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle');
dropdown.click();
@@ -48,24 +64,54 @@ ipcRenderer.on('show-notification-settings', () => {
const notificationItem: NodeListOf<HTMLElement> = document.querySelectorAll('.normal-settings-list li div');
// Wait until the notification dom element shows up
// wait until the notification dom element shows up
setTimeout(() => {
notificationItem[2].click();
}, 100);
};
process.once('loaded', (): void => {
globalPatched.logout = logout;
globalPatched.shortcut = shortcut;
globalPatched.showNotificationSettings = showNotificationSettings;
});
electron_bridge.once('zulip-loaded', ({serverLanguage}) => {
// To prevent failing this script on linux we need to load it after the document loaded
document.addEventListener('DOMContentLoaded', (): void => {
if (params.isPageParams()) {
// Get the default language of the server
if (serverLanguage) {
// Init spellchecker
SetupSpellChecker.init(serverLanguage);
}
const serverLanguage = page_params.default_language; // eslint-disable-line no-undef
if (serverLanguage) {
// Init spellchecker
SetupSpellChecker.init(serverLanguage);
}
// redirect users to network troubleshooting page
const getRestartButton = document.querySelector('.restart_get_events_button');
if (getRestartButton) {
getRestartButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'reload-viewer');
});
}
// Open image attachment link in the lightbox instead of opening in the default browser
const { $, lightbox } = window;
$('#main_div').on('click', '.message_content p a', function (this: HTMLElement, e: Event) {
const url = $(this).attr('href');
// Redirect users to network troubleshooting page
const getRestartButton = document.querySelector('.restart_get_events_button');
if (getRestartButton) {
getRestartButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'reload-viewer');
if (LinkUtil.isImage(url)) {
const $img = $(this).parent().siblings('.message_inline_image').find('img');
// prevent the image link from opening in a new page.
e.preventDefault();
// prevent the message compose dialog from happening.
e.stopPropagation();
// Open image in the default browser if image preview is unavailable
if (!$img[0]) {
shell.openExternal(window.location.origin + url);
}
// Open image in lightbox
lightbox.open($img);
}
});
}
});
@@ -80,13 +126,12 @@ window.addEventListener('load', (event: any): void => {
if (!event.target.URL.includes('app/renderer/network.html')) {
return;
}
const $reconnectButton = document.querySelector('#reconnect');
const $settingsButton = document.querySelector('#settings');
NetworkError.init($reconnectButton, $settingsButton);
});
// Electron's globalShortcut can cause unexpected results
// electron's globalShortcut can cause unexpected results
// so adding the reload shortcut in the old-school way
// Zoom from numpad keys is not supported by electron, so adding it through listeners.
document.addEventListener('keydown', event => {
@@ -107,9 +152,8 @@ ipcRenderer.on('set-active', () => {
if (isDev) {
console.log('active');
}
electron_bridge.idle_on_system = false;
electron_bridge.last_active_on_system = Date.now();
window.electron_bridge.idle_on_system = false;
window.electron_bridge.last_active_on_system = Date.now();
});
// Set user as idle and time of last activity is left unchanged
@@ -117,10 +161,5 @@ ipcRenderer.on('set-idle', () => {
if (isDev) {
console.log('idle');
}
electron_bridge.idle_on_system = true;
window.electron_bridge.idle_on_system = true;
});
(async () => webFrame.executeJavaScript(
fs.readFileSync(require.resolve('./injected'), 'utf8')
))();

View File

@@ -1,3 +1,5 @@
'use strict';
// This is a security fix. Following function prevents drag and drop event in the app
// so that attackers can't execute any remote code within the app
// It doesn't affect the compose box so that users can still
@@ -13,5 +15,3 @@ const preventDragAndDrop = (): void => {
};
preventDragAndDrop();
export {};

View File

@@ -1,61 +1,56 @@
import type {Subject} from 'rxjs';
import {SpellCheckHandler, ContextMenuListener, ContextMenuBuilder} from 'electron-spellchecker';
'use strict';
import * as ConfigUtil from './utils/config-util';
import Logger from './utils/logger-util';
import { SpellCheckHandler, ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker';
declare module 'electron-spellchecker' {
interface SpellCheckHandler {
currentSpellcheckerChanged: Subject<true>;
currentSpellcheckerLanguage: string;
}
}
import ConfigUtil = require('./utils/config-util');
import Logger = require('./utils/logger-util');
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
let spellCheckHandler: SpellCheckHandler;
let contextMenuListener: ContextMenuListener;
export function init(serverLanguage: string): void {
if (ConfigUtil.getConfigItem('enableSpellchecker')) {
enableSpellChecker();
class SetupSpellChecker {
SpellCheckHandler: typeof SpellCheckHandler;
contextMenuListener: typeof ContextMenuListener;
init(serverLanguage: string): void {
if (ConfigUtil.getConfigItem('enableSpellchecker')) {
this.enableSpellChecker();
}
this.enableContextMenu(serverLanguage);
}
enableContextMenu(serverLanguage);
}
function enableSpellChecker(): void {
try {
spellCheckHandler = new SpellCheckHandler();
} catch (error) {
logger.error(error);
enableSpellChecker(): void {
try {
this.SpellCheckHandler = new SpellCheckHandler();
} catch (err) {
logger.error(err);
}
}
}
function enableContextMenu(serverLanguage: string): void {
if (spellCheckHandler) {
spellCheckHandler.attachToInput();
spellCheckHandler.switchLanguage(serverLanguage);
spellCheckHandler.currentSpellcheckerChanged.subscribe(() => {
spellCheckHandler.switchLanguage(spellCheckHandler.currentSpellcheckerLanguage);
enableContextMenu(serverLanguage: string): void {
if (this.SpellCheckHandler) {
this.SpellCheckHandler.attachToInput();
this.SpellCheckHandler.switchLanguage(serverLanguage);
this.SpellCheckHandler.currentSpellcheckerChanged.subscribe(() => {
this.SpellCheckHandler.switchLanguage(this.SpellCheckHandler.currentSpellcheckerLanguage);
});
}
const contextMenuBuilder = new ContextMenuBuilder(this.SpellCheckHandler);
this.contextMenuListener = new ContextMenuListener((info: object) => {
contextMenuBuilder.showPopupMenu(info);
});
}
const contextMenuBuilder = new ContextMenuBuilder(spellCheckHandler);
contextMenuListener = new ContextMenuListener(info => {
contextMenuBuilder.showPopupMenu(info);
});
}
export function unsubscribeSpellChecker(): void {
if (spellCheckHandler) {
spellCheckHandler.unsubscribe();
}
if (contextMenuListener) {
contextMenuListener.unsubscribe();
unsubscribeSpellChecker(): void {
if (this.SpellCheckHandler) {
this.SpellCheckHandler.unsubscribe();
}
if (this.contextMenuListener) {
this.contextMenuListener.unsubscribe();
}
}
}
export = new SetupSpellChecker();

View File

@@ -1,26 +1,18 @@
import {ipcRenderer, remote, WebviewTag, NativeImage} from 'electron';
'use strict';
import { ipcRenderer, remote, WebviewTag, NativeImage } from 'electron';
import path from 'path';
import * as ConfigUtil from './utils/config-util';
import path = require('path');
import ConfigUtil = require('./utils/config-util.js');
const { Tray, Menu, nativeImage, BrowserWindow } = remote;
const {Tray, Menu, nativeImage, BrowserWindow, nativeTheme} = remote;
const APP_ICON = path.join(__dirname, '../../resources/tray', 'tray');
let tray: Electron.Tray;
// Get the theme on macOS
const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
const ICON_DIR = process.platform === 'darwin' ? `../../resources/tray/${theme}` : '../../resources/tray';
const TRAY_SUFFIX = 'tray';
const APP_ICON = path.join(__dirname, ICON_DIR, TRAY_SUFFIX);
declare let window: ZulipWebWindow;
const iconPath = (): string => {
if (process.platform === 'linux') {
return APP_ICON + 'linux.png';
}
return APP_ICON + (process.platform === 'win32' ? 'win.ico' : 'osx.png');
};
@@ -51,60 +43,69 @@ const config = {
thick: process.platform === 'win32'
};
const renderCanvas = function (arg: number): HTMLCanvasElement {
const renderCanvas = function (arg: number): Promise<HTMLCanvasElement> {
config.unreadCount = arg;
const SIZE = config.size * config.pixelRatio;
const PADDING = SIZE * 0.05;
const CENTER = SIZE / 2;
const HAS_COUNT = config.showUnreadCount && config.unreadCount;
const color = config.unreadCount ? config.unreadColor : config.readColor;
const backgroundColor = config.unreadCount ? config.unreadBackgroundColor : config.readBackgroundColor;
return new Promise(resolve => {
const SIZE = config.size * config.pixelRatio;
const PADDING = SIZE * 0.05;
const CENTER = SIZE / 2;
const HAS_COUNT = config.showUnreadCount && config.unreadCount;
const color = config.unreadCount ? config.unreadColor : config.readColor;
const backgroundColor = config.unreadCount ? config.unreadBackgroundColor : config.readBackgroundColor;
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
// Circle
// If (!config.thick || config.thick && HAS_COUNT) {
ctx.beginPath();
ctx.arc(CENTER, CENTER, (SIZE / 2) - PADDING, 0, 2 * Math.PI, false);
ctx.fillStyle = backgroundColor;
ctx.fill();
ctx.lineWidth = SIZE / (config.thick ? 10 : 20);
ctx.strokeStyle = backgroundColor;
ctx.stroke();
// Count or Icon
if (HAS_COUNT) {
ctx.fillStyle = color;
ctx.textAlign = 'center';
if (config.unreadCount > 99) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.4}px Helvetica`;
ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15));
} else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.2));
} else {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15));
// Circle
// If (!config.thick || config.thick && HAS_COUNT) {
ctx.beginPath();
ctx.arc(CENTER, CENTER, (SIZE / 2) - PADDING, 0, 2 * Math.PI, false);
ctx.fillStyle = backgroundColor;
ctx.fill();
ctx.lineWidth = SIZE / (config.thick ? 10 : 20);
ctx.strokeStyle = backgroundColor;
ctx.stroke();
// Count or Icon
if (HAS_COUNT) {
ctx.fillStyle = color;
ctx.textAlign = 'center';
if (config.unreadCount > 99) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.4}px Helvetica`;
ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15));
} else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.20));
} else {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15));
}
resolve(canvas);
}
}
return canvas;
});
};
/**
* Renders the tray icon as a native image
* @param arg: Unread count
* @return the native image
*/
const renderNativeImage = function (arg: number): NativeImage {
const canvas = renderCanvas(arg);
const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG();
return nativeImage.createFromBuffer(pngData, {
scaleFactor: config.pixelRatio
});
const renderNativeImage = function (arg: number): Promise<NativeImage> {
return Promise.resolve()
.then(() => renderCanvas(arg))
.then(canvas => {
const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG();
// TODO: Fix the function to correctly use Promise correctly.
// the Promise.resolve().then(...) above is useless we should
// start with renderCanvas(arg).then
// eslint-disable-next-line promise/no-return-wrap
return Promise.resolve(nativeImage.createFromBuffer(pngData, {
scaleFactor: config.pixelRatio
}));
});
};
function sendAction(action: string): void {
@@ -118,6 +119,7 @@ function sendAction(action: string): void {
}
const createTray = function (): void {
window.tray = new Tray(iconPath());
const contextMenu = Menu.buildFromTemplate([
{
label: 'Zulip',
@@ -142,23 +144,22 @@ const createTray = function (): void {
}
}
]);
tray = new Tray(iconPath());
tray.setContextMenu(contextMenu);
window.tray.setContextMenu(contextMenu);
if (process.platform === 'linux' || process.platform === 'win32') {
tray.on('click', () => {
window.tray.on('click', () => {
ipcRenderer.send('toggle-app');
});
}
};
ipcRenderer.on('destroytray', (event: Event): Event => {
if (!tray) {
if (!window.tray) {
return undefined;
}
tray.destroy();
if (tray.isDestroyed()) {
tray = null;
window.tray.destroy();
if (window.tray.isDestroyed()) {
window.tray = null;
} else {
throw new Error('Tray icon not properly destroyed.');
}
@@ -167,47 +168,45 @@ ipcRenderer.on('destroytray', (event: Event): Event => {
});
ipcRenderer.on('tray', (_event: Event, arg: number): void => {
if (!tray) {
if (!window.tray) {
return;
}
// We don't want to create tray from unread messages on macOS since it already has dock badges.
if (process.platform === 'linux' || process.platform === 'win32') {
if (arg === 0) {
unread = arg;
tray.setImage(iconPath());
tray.setToolTip('No unread messages');
window.tray.setImage(iconPath());
window.tray.setToolTip('No unread messages');
} else {
unread = arg;
const image = renderNativeImage(arg);
tray.setImage(image);
tray.setToolTip(`${arg} unread messages`);
renderNativeImage(arg).then(image => {
window.tray.setImage(image);
window.tray.setToolTip(arg + ' unread messages');
});
}
}
});
function toggleTray(): void {
let state;
if (tray) {
if (window.tray) {
state = false;
tray.destroy();
if (tray.isDestroyed()) {
tray = null;
window.tray.destroy();
if (window.tray.isDestroyed()) {
window.tray = null;
}
ConfigUtil.setConfigItem('trayIcon', false);
} else {
state = true;
createTray();
if (process.platform === 'linux' || process.platform === 'win32') {
const image = renderNativeImage(unread);
tray.setImage(image);
tray.setToolTip(`${unread} unread messages`);
renderNativeImage(unread).then(image => {
window.tray.setImage(image);
window.tray.setToolTip(unread + ' unread messages');
});
}
ConfigUtil.setConfigItem('trayIcon', true);
}
const selector = 'webview:not([class*=disabled])';
const webview: WebviewTag = document.querySelector(selector);
const webContents = webview.getWebContents();
@@ -219,5 +218,3 @@ ipcRenderer.on('toggletray', toggleTray);
if (ConfigUtil.getConfigItem('trayIcon', true)) {
createTray();
}
export {};

View File

@@ -1,78 +1,96 @@
import electron from 'electron';
import {JsonDB} from 'node-json-db';
import {initSetUp} from './default-util';
'use strict';
import fs from 'fs';
import path from 'path';
import Logger from './logger-util';
import { remote } from 'electron';
import JsonDB from 'node-json-db';
import { initSetUp } from './default-util';
const {app, dialog} =
process.type === 'renderer' ? electron.remote : electron;
import fs = require('fs');
import path = require('path');
import Logger = require('./logger-util');
const { app, dialog } = remote;
initSetUp();
const logger = new Logger({
file: 'certificate-util.log',
file: `certificate-util.log`,
timestamp: true
});
let instance: null | CertificateUtil = null;
const certificatesDir = `${app.getPath('userData')}/certificates`;
let db: JsonDB;
class CertificateUtil {
db: JsonDB;
reloadDB();
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
export function getCertificate(server: string): string | undefined {
reloadDB();
return db.getData('/')[server];
}
// Function to copy the certificate to userData folder
export function copyCertificate(_server: string, location: string, fileName: string): boolean {
let copied = false;
const filePath = `${certificatesDir}/${fileName}`;
try {
fs.copyFileSync(location, filePath);
copied = true;
} catch (error) {
dialog.showErrorBox(
'Error saving certificate',
'We encountered error while saving the certificate.'
);
logger.error('Error while copying the certificate to certificates folder.');
logger.error(error);
this.reloadDB();
return instance;
}
return copied;
}
export function setCertificate(server: string, fileName: string): void {
const filePath = `${fileName}`;
db.push(`/${server}`, filePath, true);
reloadDB();
}
export function removeCertificate(server: string): void {
db.delete(`/${server}`);
reloadDB();
}
function reloadDB(): void {
const settingsJsonPath = path.join(app.getPath('userData'), '/config/certificates.json');
try {
const file = fs.readFileSync(settingsJsonPath, 'utf8');
JSON.parse(file);
} catch (error) {
if (fs.existsSync(settingsJsonPath)) {
fs.unlinkSync(settingsJsonPath);
dialog.showErrorBox(
'Error saving settings',
'We encountered error while saving the certificate.'
);
logger.error('Error while JSON parsing certificates.json: ');
logger.error(error);
getCertificate(server: string, defaultValue: any = null): any {
this.reloadDB();
const value = this.db.getData('/')[server];
if (value === undefined) {
return defaultValue;
} else {
return value;
}
}
db = new JsonDB(settingsJsonPath, true, true);
// Function to copy the certificate to userData folder
copyCertificate(_server: string, location: string, fileName: string): boolean {
let copied = false;
const filePath = `${certificatesDir}/${fileName}`;
try {
fs.copyFileSync(location, filePath);
copied = true;
} catch (err) {
dialog.showErrorBox(
'Error saving certificate',
'We encountered error while saving the certificate.'
);
logger.error('Error while copying the certificate to certificates folder.');
logger.error(err);
}
return copied;
}
setCertificate(server: string, fileName: string): void {
const filePath = `${fileName}`;
this.db.push(`/${server}`, filePath, true);
this.reloadDB();
}
removeCertificate(server: string): void {
this.db.delete(`/${server}`);
this.reloadDB();
}
reloadDB(): void {
const settingsJsonPath = path.join(app.getPath('userData'), '/config/certificates.json');
try {
const file = fs.readFileSync(settingsJsonPath, 'utf8');
JSON.parse(file);
} catch (err) {
if (fs.existsSync(settingsJsonPath)) {
fs.unlinkSync(settingsJsonPath);
dialog.showErrorBox(
'Error saving settings',
'We encountered error while saving the certificate.'
);
logger.error('Error while JSON parsing certificates.json: ');
logger.error(err);
}
}
this.db = new JsonDB(settingsJsonPath, true, true);
}
}
export = new CertificateUtil();

View File

@@ -1,8 +1,25 @@
// Unescape already encoded/escaped strings
export function decodeString(stringInput: string): string {
const parser = new DOMParser();
const dom = parser.parseFromString(
'<!doctype html><body>' + stringInput,
'text/html');
return dom.body.textContent;
'use strict';
let instance: null | CommonUtil = null;
class CommonUtil {
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
return instance;
}
// unescape already encoded/escaped strings
decodeString(stringInput: string): string {
const parser = new DOMParser();
const dom = parser.parseFromString(
'<!doctype html><body>' + stringInput,
'text/html');
return dom.body.textContent;
}
}
export = new CommonUtil();

View File

@@ -1,22 +1,24 @@
import {JsonDB} from 'node-json-db';
'use strict';
import JsonDB from 'node-json-db';
import fs from 'fs';
import path from 'path';
import electron from 'electron';
import Logger from './logger-util';
import * as EnterpriseUtil from './enterprise-util';
import fs = require('fs');
import path = require('path');
import electron = require('electron');
import Logger = require('./logger-util');
import EnterpriseUtil = require('./enterprise-util');
const logger = new Logger({
file: 'config-util.log',
timestamp: true
});
let instance: null | ConfigUtil = null;
let dialog: Electron.Dialog = null;
let app: Electron.App = null;
/* To make the util runnable in both main and renderer process */
if (process.type === 'renderer') {
const {remote} = electron;
const { remote } = electron;
dialog = remote.dialog;
app = remote.app;
} else {
@@ -24,82 +26,81 @@ if (process.type === 'renderer') {
app = electron.app;
}
let db: JsonDB;
class ConfigUtil {
db: JsonDB;
reloadDB();
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
export function getConfigItem(key: string, defaultValue: unknown = null): any {
try {
db.reload();
} catch (error) {
logger.error('Error while reloading settings.json: ');
logger.error(error);
this.reloadDB();
return instance;
}
const value = db.getData('/')[key];
if (value === undefined) {
setConfigItem(key, defaultValue);
return defaultValue;
}
return value;
}
export function getConfigString(key: string, defaultValue: string): string {
const value = getConfigItem(key, defaultValue);
if (typeof value === 'string') {
return value;
}
setConfigItem(key, defaultValue);
return defaultValue;
}
// This function returns whether a key exists in the configuration file (settings.json)
export function isConfigItemExists(key: string): boolean {
try {
db.reload();
} catch (error) {
logger.error('Error while reloading settings.json: ');
logger.error(error);
}
const value = db.getData('/')[key];
return (value !== undefined);
}
export function setConfigItem(key: string, value: unknown, override? : boolean): void {
if (EnterpriseUtil.configItemExists(key) && !override) {
// If item is in global config and we're not trying to override
return;
}
db.push(`/${key}`, value, true);
db.save();
}
export function removeConfigItem(key: string): void {
db.delete(`/${key}`);
db.save();
}
function reloadDB(): void {
const settingsJsonPath = path.join(app.getPath('userData'), '/config/settings.json');
try {
const file = fs.readFileSync(settingsJsonPath, 'utf8');
JSON.parse(file);
} catch (error) {
if (fs.existsSync(settingsJsonPath)) {
fs.unlinkSync(settingsJsonPath);
dialog.showErrorBox(
'Error saving settings',
'We encountered an error while saving the settings.'
);
logger.error('Error while JSON parsing settings.json: ');
logger.error(error);
logger.reportSentry(error);
getConfigItem(key: string, defaultValue: any = null): any {
try {
this.db.reload();
} catch (err) {
logger.error('Error while reloading settings.json: ');
logger.error(err);
}
const value = this.db.getData('/')[key];
if (value === undefined) {
this.setConfigItem(key, defaultValue);
return defaultValue;
} else {
return value;
}
}
db = new JsonDB(settingsJsonPath, true, true);
// This function returns whether a key exists in the configuration file (settings.json)
isConfigItemExists(key: string): boolean {
try {
this.db.reload();
} catch (err) {
logger.error('Error while reloading settings.json: ');
logger.error(err);
}
const value = this.db.getData('/')[key];
return (value !== undefined);
}
setConfigItem(key: string, value: any, override? : boolean): void {
if (EnterpriseUtil.configItemExists(key) && !override) {
// if item is in global config and we're not trying to override
return;
}
this.db.push(`/${key}`, value, true);
this.db.save();
}
removeConfigItem(key: string): void {
this.db.delete(`/${key}`);
this.db.save();
}
reloadDB(): void {
const settingsJsonPath = path.join(app.getPath('userData'), '/config/settings.json');
try {
const file = fs.readFileSync(settingsJsonPath, 'utf8');
JSON.parse(file);
} catch (err) {
if (fs.existsSync(settingsJsonPath)) {
fs.unlinkSync(settingsJsonPath);
dialog.showErrorBox(
'Error saving settings',
'We encountered an error while saving the settings.'
);
logger.error('Error while JSON parsing settings.json: ');
logger.error(err);
logger.reportSentry(err);
}
}
this.db = new JsonDB(settingsJsonPath, true, true);
}
}
export = new ConfigUtil();

View File

@@ -1,12 +1,11 @@
import electron from 'electron';
import fs from 'fs';
import fs = require('fs');
let app: Electron.App = null;
let setupCompleted = false;
if (process.type === 'renderer') {
app = electron.remote.app;
app = require('electron').remote.app;
} else {
app = electron.app;
app = require('electron').app;
}
const zulipDir = app.getPath('userData');
@@ -14,7 +13,7 @@ const logDir = `${zulipDir}/Logs/`;
const certificatesDir = `${zulipDir}/certificates/`;
const configDir = `${zulipDir}/config/`;
export const initSetUp = (): void => {
// If it is the first time the app is running
// if it is the first time the app is running
// create zulip dir in userData folder to
// avoid errors
if (!setupCompleted) {
@@ -42,19 +41,19 @@ export const initSetUp = (): void => {
const configData = [
{
path: domainJson,
fileName: 'domain.json'
fileName: `domain.json`
},
{
path: certificatesJson,
fileName: 'certificates.json'
fileName: `certificates.json`
},
{
path: settingsJson,
fileName: 'settings.json'
fileName: `settings.json`
},
{
path: updatesJson,
fileName: 'updates.json'
fileName: `updates.json`
}
];
configData.forEach(data => {
@@ -63,7 +62,7 @@ export const initSetUp = (): void => {
fs.unlinkSync(data.path);
}
});
// `window-state.json` is only deleted not moved, as the electron-window-state
// window-state.json is only deleted not moved, as the electron-window-state
// package will recreate the file in the config folder.
if (fs.existsSync(windowStateJson)) {
fs.unlinkSync(windowStateJson);

View File

@@ -1,28 +1,30 @@
import * as ConfigUtil from './config-util';
'use strict';
type SettingName = 'showNotification' | 'silent' | 'flashTaskbarOnMessage';
import ConfigUtil = require('./config-util');
export interface DNDSettings {
showNotification?: boolean;
silent?: boolean;
flashTaskbarOnMessage?: boolean;
// TODO: TypeScript - add to Setting interface
// the list of settings since we have fixed amount of them
// We want to do this by creating a new module that exports
// this interface
interface Setting {
[key: string]: any;
}
interface Toggle {
dnd: boolean;
newSettings: DNDSettings;
newSettings: Setting;
}
export function toggle(): Toggle {
const dnd = !ConfigUtil.getConfigItem('dnd', false);
const dndSettingList: SettingName[] = ['showNotification', 'silent'];
const dndSettingList = ['showNotification', 'silent'];
if (process.platform === 'win32') {
dndSettingList.push('flashTaskbarOnMessage');
}
let newSettings: DNDSettings;
let newSettings: Setting;
if (dnd) {
const oldSettings: DNDSettings = {};
const oldSettings: Setting = {};
newSettings = {};
// Iterate through the dndSettingList.

View File

@@ -1,321 +1,331 @@
import {JsonDB} from 'node-json-db';
'use strict';
import JsonDB from 'node-json-db';
import escape from 'escape-html';
import request from 'request';
import fs from 'fs';
import path from 'path';
import Logger from './logger-util';
import {remote} from 'electron';
import escape = require('escape-html');
import request = require('request');
import fs = require('fs');
import path = require('path');
import Logger = require('./logger-util');
import electron = require('electron');
import * as RequestUtil from './request-util';
import * as EnterpriseUtil from './enterprise-util';
import * as Messages from '../../../resources/messages';
import RequestUtil = require('./request-util');
import EnterpriseUtil = require('./enterprise-util');
import Messages = require('../../../resources/messages');
const {app, dialog} = remote;
export interface ServerConf {
url: string;
alias?: string;
icon?: string;
ignoreCerts?: boolean;
}
const { ipcRenderer } = electron;
const { app, dialog } = electron.remote;
const logger = new Logger({
file: 'domain-util.log',
file: `domain-util.log`,
timestamp: true
});
let instance: null | DomainUtil = null;
const defaultIconUrl = '../renderer/img/icon.png';
let db: JsonDB;
class DomainUtil {
db: JsonDB;
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
reloadDB();
// Migrate from old schema
if (db.getData('/').domain) {
(async () => {
await addDomain({
alias: 'Zulip',
url: db.getData('/domain')
});
db.delete('/domain');
})();
}
this.reloadDB();
// Migrate from old schema
if (this.db.getData('/').domain) {
this.addDomain({
alias: 'Zulip',
url: this.db.getData('/domain')
});
this.db.delete('/domain');
}
export function getDomains(): ServerConf[] {
reloadDB();
if (db.getData('/').domains === undefined) {
return [];
return instance;
}
return db.getData('/domains');
}
export function getDomain(index: number): ServerConf {
reloadDB();
return db.getData(`/domains[${index}]`);
}
export function shouldIgnoreCerts(url: string): boolean {
const domains = getDomains();
for (const domain of domains) {
if (domain.url === url) {
return domain.ignoreCerts;
getDomains(): any {
this.reloadDB();
if (this.db.getData('/').domains === undefined) {
return [];
} else {
return this.db.getData('/domains');
}
}
return null;
}
export function updateDomain(index: number, server: ServerConf): void {
reloadDB();
db.push(`/domains[${index}]`, server, true);
}
export async function addDomain(server: ServerConf): Promise<void> {
const {ignoreCerts} = server;
if (server.icon) {
const localIconUrl = await saveServerIcon(server, ignoreCerts);
server.icon = localIconUrl;
db.push('/domains[]', server, true);
reloadDB();
} else {
server.icon = defaultIconUrl;
db.push('/domains[]', server, true);
reloadDB();
getDomain(index: number): any {
this.reloadDB();
return this.db.getData(`/domains[${index}]`);
}
}
export function removeDomains(): void {
db.delete('/domains');
reloadDB();
}
shouldIgnoreCerts(url: string): boolean {
const domains = this.getDomains();
for (const domain of domains) {
if (domain.url === url) {
return domain.ignoreCerts;
}
}
return null;
}
export function removeDomain(index: number): boolean {
if (EnterpriseUtil.isPresetOrg(getDomain(index).url)) {
updateDomain(index: number, server: object): void {
this.reloadDB();
this.db.push(`/domains[${index}]`, server, true);
}
addDomain(server: any): Promise<void> {
const { ignoreCerts } = server;
return new Promise(resolve => {
if (server.icon) {
this.saveServerIcon(server, ignoreCerts).then(localIconUrl => {
server.icon = localIconUrl;
this.db.push('/domains[]', server, true);
this.reloadDB();
resolve();
});
} else {
server.icon = defaultIconUrl;
this.db.push('/domains[]', server, true);
this.reloadDB();
resolve();
}
});
}
removeDomains(): void {
this.db.delete('/domains');
this.reloadDB();
}
removeDomain(index: number): boolean {
if (EnterpriseUtil.isPresetOrg(this.getDomain(index).url)) {
return false;
}
this.db.delete(`/domains[${index}]`);
this.reloadDB();
return true;
}
// Check if domain is already added
duplicateDomain(domain: any): boolean {
domain = this.formatUrl(domain);
const servers = this.getDomains();
for (const i in servers) {
if (servers[i].url === domain) {
return true;
}
}
return false;
}
db.delete(`/domains[${index}]`);
reloadDB();
return true;
}
// Check if domain is already added
export function duplicateDomain(domain: string): boolean {
domain = formatUrl(domain);
return getDomains().some(server => server.url === domain);
}
async function checkCertError(domain: string, serverConf: ServerConf, error: any, silent: boolean): Promise<ServerConf> {
if (silent) {
// Since getting server settings has already failed
return serverConf;
}
// Report error to sentry to get idea of possible certificate errors
// users get when adding the servers
logger.reportSentry(error);
const certErrorMessage = Messages.certErrorMessage(domain, error);
const certErrorDetail = Messages.certErrorDetail();
const {response} = await dialog.showMessageBox({
type: 'warning',
buttons: ['Yes', 'No'],
defaultId: 1,
message: certErrorMessage,
detail: certErrorDetail
});
if (response === 0) {
// Set ignoreCerts parameter to true in case user responds with yes
serverConf.ignoreCerts = true;
try {
return await getServerSettings(domain, serverConf.ignoreCerts);
} catch (_) {
if (error === Messages.noOrgsError(domain)) {
throw new Error(error);
}
async checkCertError(domain: any, serverConf: any, error: string, silent: boolean): Promise<string | object> {
if (silent) {
// since getting server settings has already failed
return serverConf;
}
} else {
throw new Error('Untrusted certificate.');
}
}
} else {
// Report error to sentry to get idea of possible certificate errors
// users get when adding the servers
logger.reportSentry(new Error(error).toString());
const certErrorMessage = Messages.certErrorMessage(domain, error);
const certErrorDetail = Messages.certErrorDetail();
// ignoreCerts parameter helps in fetching server icon and
// other server details when user chooses to ignore certificate warnings
export async function checkDomain(domain: string, ignoreCerts = false, silent = false): Promise<ServerConf> {
if (!silent && duplicateDomain(domain)) {
// Do not check duplicate in silent mode
throw new Error('This server has been added.');
}
domain = formatUrl(domain);
const serverConf = {
icon: defaultIconUrl,
url: domain,
alias: domain,
ignoreCerts
};
try {
return await getServerSettings(domain, serverConf.ignoreCerts);
} catch (error_) {
// Make sure that error is an error or string not undefined
// so validation does not throw error.
const error = error_ || '';
const certsError = error.toString().includes('certificate');
if (certsError) {
const result = await checkCertError(domain, serverConf, error, silent);
return result;
}
throw new Error(Messages.invalidZulipServerError(domain));
}
}
async function getServerSettings(domain: string, ignoreCerts = false): Promise<ServerConf> {
const serverSettingsOptions = {
url: domain + '/api/v1/server_settings',
...RequestUtil.requestOptions(domain, ignoreCerts)
};
return new Promise((resolve, reject) => {
request(serverSettingsOptions, (error: Error, response: request.Response) => {
if (!error && response.statusCode === 200) {
const {realm_name, realm_uri, realm_icon} = JSON.parse(response.body);
if (
typeof realm_name === 'string' &&
typeof realm_uri === 'string' &&
typeof realm_icon === 'string'
) {
resolve({
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
// Following check handles both the cases
icon: realm_icon.startsWith('/') ? realm_uri + realm_icon : realm_icon,
url: realm_uri,
alias: escape(realm_name),
ignoreCerts
});
} else {
reject(Messages.noOrgsError(domain));
const response = await (dialog.showMessageBox({
type: 'warning',
buttons: ['Yes', 'No'],
defaultId: 1,
message: certErrorMessage,
detail: certErrorDetail
}) as any); // TODO: TypeScript - Figure this out
if (response === 0) {
// set ignoreCerts parameter to true in case user responds with yes
serverConf.ignoreCerts = true;
try {
return await this.getServerSettings(domain, serverConf.ignoreCerts);
} catch (_) {
if (error === Messages.noOrgsError(domain)) {
throw new Error(error);
}
return serverConf;
}
} else {
reject(response);
throw new Error('Untrusted certificate.');
}
});
});
}
}
}
export async function saveServerIcon(server: ServerConf, ignoreCerts = false): Promise<string> {
const url = server.icon;
const domain = server.url;
// ignoreCerts parameter helps in fetching server icon and
// other server details when user chooses to ignore certificate warnings
async checkDomain(domain: any, ignoreCerts = false, silent = false): Promise<any> {
if (!silent && this.duplicateDomain(domain)) {
// Do not check duplicate in silent mode
throw new Error('This server has been added.');
}
const serverIconOptions = {
url,
...RequestUtil.requestOptions(domain, ignoreCerts)
};
domain = this.formatUrl(domain);
const serverConf = {
icon: defaultIconUrl,
url: domain,
alias: domain,
ignoreCerts
};
// The save will always succeed. If url is invalid, downgrade to default icon.
return new Promise(resolve => {
const filePath = generateFilePath(url);
const file = fs.createWriteStream(filePath);
try {
request(serverIconOptions).on('response', (response: request.Response) => {
response.on('error', (err: Error) => {
return await this.getServerSettings(domain, serverConf.ignoreCerts);
} catch (err) {
// If the domain contains following strings we just bypass the server
const whitelistDomains = [
'zulipdev.org'
];
// make sure that error is an error or string not undefined
// so validation does not throw error.
const error = err || '';
const certsError = error.toString().includes('certificate');
if (domain.indexOf(whitelistDomains) >= 0 || certsError) {
return this.checkCertError(domain, serverConf, error, silent);
} else {
throw Messages.invalidZulipServerError(domain);
}
}
}
getServerSettings(domain: any, ignoreCerts = false): Promise<object | string> {
const serverSettingsOptions = {
url: domain + '/api/v1/server_settings',
...RequestUtil.requestOptions(domain, ignoreCerts)
};
return new Promise((resolve, reject) => {
request(serverSettingsOptions, (error: string, response: any) => {
if (!error && response.statusCode === 200) {
const data = JSON.parse(response.body);
if (data.hasOwnProperty('realm_icon') && data.realm_icon) {
resolve({
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
// Following check handles both the cases
icon: data.realm_icon.startsWith('/') ? data.realm_uri + data.realm_icon : data.realm_icon,
url: data.realm_uri,
alias: escape(data.realm_name),
ignoreCerts
});
} else {
reject(Messages.noOrgsError(domain));
}
} else {
reject(response);
}
});
});
}
saveServerIcon(server: any, ignoreCerts = false): Promise<string> {
const url = server.icon;
const domain = server.url;
const serverIconOptions = {
url,
...RequestUtil.requestOptions(domain, ignoreCerts)
};
// The save will always succeed. If url is invalid, downgrade to default icon.
return new Promise(resolve => {
const filePath = this.generateFilePath(url);
const file = fs.createWriteStream(filePath);
try {
request(serverIconOptions).on('response', (response: any) => {
response.on('error', (err: string) => {
logger.log('Could not get server icon.');
logger.log(err);
logger.reportSentry(err);
resolve(defaultIconUrl);
});
response.pipe(file).on('finish', () => {
resolve(filePath);
});
}).on('error', (err: string) => {
logger.log('Could not get server icon.');
logger.log(err);
logger.reportSentry(err);
resolve(defaultIconUrl);
});
response.pipe(file).on('finish', () => {
resolve(filePath);
});
}).on('error', (err: Error) => {
} catch (err) {
logger.log('Could not get server icon.');
logger.log(err);
logger.reportSentry(err);
resolve(defaultIconUrl);
});
} catch (error) {
logger.log('Could not get server icon.');
logger.log(error);
logger.reportSentry(error);
resolve(defaultIconUrl);
}
});
}
export async function updateSavedServer(url: string, index: number): Promise<void> {
// Does not promise successful update
const oldIcon = getDomain(index).icon;
const {ignoreCerts} = getDomain(index);
try {
const newServerConf = await checkDomain(url, ignoreCerts, true);
const localIconUrl = await saveServerIcon(newServerConf, ignoreCerts);
if (!oldIcon || localIconUrl !== '../renderer/img/icon.png') {
newServerConf.icon = localIconUrl;
updateDomain(index, newServerConf);
reloadDB();
}
} catch (error) {
logger.log('Could not update server icon.');
logger.log(error);
logger.reportSentry(error);
}
});
}
}
function reloadDB(): void {
const domainJsonPath = path.join(app.getPath('userData'), 'config/domain.json');
try {
const file = fs.readFileSync(domainJsonPath, 'utf8');
JSON.parse(file);
} catch (error) {
if (fs.existsSync(domainJsonPath)) {
fs.unlinkSync(domainJsonPath);
dialog.showErrorBox(
'Error saving new organization',
'There seems to be error while saving new organization, ' +
'you may have to re-add your previous organizations back.'
);
logger.error('Error while JSON parsing domain.json: ');
logger.error(error);
logger.reportSentry(error);
async updateSavedServer(url: string, index: number): Promise<void> {
// Does not promise successful update
const oldIcon = this.getDomain(index).icon;
const { ignoreCerts } = this.getDomain(index);
try {
const newServerConf = await this.checkDomain(url, ignoreCerts, true);
const localIconUrl = await this.saveServerIcon(newServerConf, ignoreCerts);
if (!oldIcon || localIconUrl !== '../renderer/img/icon.png') {
newServerConf.icon = localIconUrl;
this.updateDomain(index, newServerConf);
this.reloadDB();
}
} catch (err) {
ipcRenderer.send('forward-message', 'show-network-error', index);
}
}
db = new JsonDB(domainJsonPath, true, true);
reloadDB(): void {
const domainJsonPath = path.join(app.getPath('userData'), 'config/domain.json');
try {
const file = fs.readFileSync(domainJsonPath, 'utf8');
JSON.parse(file);
} catch (err) {
if (fs.existsSync(domainJsonPath)) {
fs.unlinkSync(domainJsonPath);
dialog.showErrorBox(
'Error saving new organization',
'There seems to be error while saving new organization, ' +
'you may have to re-add your previous organizations back.'
);
logger.error('Error while JSON parsing domain.json: ');
logger.error(err);
logger.reportSentry(err);
}
}
this.db = new JsonDB(domainJsonPath, true, true);
}
generateFilePath(url: string): string {
const dir = `${app.getPath('userData')}/server-icons`;
const extension = path.extname(url).split('?')[0];
let hash = 5381;
let len = url.length;
while (len) {
hash = (hash * 33) ^ url.charCodeAt(--len);
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
return `${dir}/${hash >>> 0}${extension}`;
}
formatUrl(domain: any): string {
const hasPrefix = (domain.indexOf('http') === 0);
if (hasPrefix) {
return domain;
} else {
return (domain.indexOf('localhost:') >= 0) ? `http://${domain}` : `https://${domain}`;
}
}
}
function generateFilePath(url: string): string {
const dir = `${app.getPath('userData')}/server-icons`;
const extension = path.extname(url).split('?')[0];
let hash = 5381;
let {length} = url;
while (length) {
hash = (hash * 33) ^ url.charCodeAt(--length);
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
return `${dir}/${hash >>> 0}${extension}`;
}
export function formatUrl(domain: string): string {
if (domain.startsWith('http://') || domain.startsWith('https://')) {
return domain;
}
if (domain.startsWith('localhost:')) {
return `http://${domain}`;
}
return `https://${domain}`;
}
export = new DomainUtil();

View File

@@ -1,81 +1,80 @@
import fs from 'fs';
import path from 'path';
import fs = require('fs');
import path = require('path');
import Logger from './logger-util';
import Logger = require('./logger-util');
const logger = new Logger({
file: 'enterprise-util.log',
timestamp: true
});
// TODO: replace enterpriseSettings type with an interface once settings are final
let enterpriseSettings: {[key: string]: unknown};
let configFile: boolean;
let instance: null | EnterpriseUtil = null;
reloadDB();
function reloadDB(): void {
let enterpriseFile = '/etc/zulip-desktop-config/global_config.json';
if (process.platform === 'win32') {
enterpriseFile = 'C:\\Program Files\\Zulip-Desktop-Config\\global_config.json';
}
enterpriseFile = path.resolve(enterpriseFile);
if (fs.existsSync(enterpriseFile)) {
configFile = true;
try {
const file = fs.readFileSync(enterpriseFile, 'utf8');
enterpriseSettings = JSON.parse(file);
} catch (error) {
logger.log('Error while JSON parsing global_config.json: ');
logger.log(error);
class EnterpriseUtil {
// todo: replace enterpriseSettings type with an interface once settings are final
enterpriseSettings: any;
configFile: boolean;
constructor() {
if (instance) {
return instance;
}
} else {
configFile = false;
}
}
instance = this;
export function hasConfigFile(): boolean {
return configFile;
}
export function getConfigItem(key: string, defaultValue?: unknown): any {
reloadDB();
if (!configFile) {
return defaultValue;
this.reloadDB();
}
if (defaultValue === undefined) {
defaultValue = null;
}
reloadDB(): void {
let enterpriseFile = '/etc/zulip-desktop-config/global_config.json';
if (process.platform === 'win32') {
enterpriseFile = 'C:\\Program Files\\Zulip-Desktop-Config\\global_config.json';
}
return configItemExists(key) ? enterpriseSettings[key] : defaultValue;
}
export function configItemExists(key: string): boolean {
reloadDB();
if (!configFile) {
return false;
}
return (enterpriseSettings[key] !== undefined);
}
export function isPresetOrg(url: string): boolean {
if (!configFile || !configItemExists('presetOrganizations')) {
return false;
}
const presetOrgs = enterpriseSettings.presetOrganizations;
if (!Array.isArray(presetOrgs)) {
throw new TypeError('Expected array for presetOrgs');
}
for (const org of presetOrgs) {
if (url.includes(org)) {
return true;
enterpriseFile = path.resolve(enterpriseFile);
if (fs.existsSync(enterpriseFile)) {
this.configFile = true;
try {
const file = fs.readFileSync(enterpriseFile, 'utf8');
this.enterpriseSettings = JSON.parse(file);
} catch (err) {
logger.log('Error while JSON parsing global_config.json: ');
logger.log(err);
}
} else {
this.configFile = false;
}
}
return false;
getConfigItem(key: string, defaultValue?: any): any {
this.reloadDB();
if (!this.configFile) {
return defaultValue;
}
if (defaultValue === undefined) {
defaultValue = null;
}
return this.configItemExists(key) ? this.enterpriseSettings[key] : defaultValue;
}
configItemExists(key: string): boolean {
this.reloadDB();
if (!this.configFile) {
return false;
}
return (this.enterpriseSettings[key] !== undefined);
}
isPresetOrg(url: string): boolean {
if (!this.configFile || !this.configItemExists('presetOrganizations')) {
return false;
}
const presetOrgs = this.enterpriseSettings.presetOrganizations;
for (const org of presetOrgs) {
if (url.includes(org)) {
return true;
}
}
return false;
}
}
export = new EnterpriseUtil();

View File

@@ -1,45 +1,51 @@
import {shell} from 'electron';
import escape from 'escape-html';
import fs from 'fs';
import os from 'os';
import path from 'path';
'use strict';
export function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith('/user_uploads/');
// TODO: TypeScript - Add @types/
import wurl = require('wurl');
let instance: null | LinkUtil = null;
interface IsInternalResponse {
isInternalUrl: boolean;
isUploadsUrl: boolean;
}
export async function openBrowser(url: URL): Promise<void> {
if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
await shell.openExternal(url.href);
} else {
// For security, indirect links to non-whitelisted protocols
// through a real web browser via a local HTML file.
const dir = fs.mkdtempSync(
path.join(os.tmpdir(), 'zulip-redirect-')
);
const file = path.join(dir, 'redirect.html');
fs.writeFileSync(file, `\
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="Refresh" content="0; url=${escape(url.href)}" />
<title>Redirecting</title>
<style>
html {
font-family: menu, "Helvetica Neue", sans-serif;
}
</style>
</head>
<body>
<p>Opening <a href="${escape(url.href)}">${escape(url.href)}</a>…</p>
</body>
</html>
`);
shell.openItem(file);
setTimeout(() => {
fs.unlinkSync(file);
fs.rmdirSync(dir);
}, 15000);
class LinkUtil {
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
return instance;
}
isInternal(currentUrl: string, newUrl: string): IsInternalResponse {
const currentDomain = wurl('hostname', currentUrl);
const newDomain = wurl('hostname', newUrl);
const sameDomainUrl = (currentDomain === newDomain || newUrl === currentUrl + '/');
const isUploadsUrl = newUrl.includes(currentUrl + '/user_uploads/');
const isInternalUrl = newUrl.includes('/#narrow') || isUploadsUrl;
return {
isInternalUrl: sameDomainUrl && isInternalUrl,
isUploadsUrl
};
}
isImage(url: string): boolean {
// test for images extension as well as urls like .png?s=100
const isImageUrl = /\.(bmp|gif|jpg|jpeg|png|webp)\?*.*$/i;
return isImageUrl.test(url);
}
isPDF(url: string): boolean {
// test for pdf extension
const isPDFUrl = /\.(pdf)\?*.*$/i;
return isPDFUrl.test(url);
}
}
export = new LinkUtil();

View File

@@ -1,9 +1,10 @@
import {JsonDB} from 'node-json-db';
'use strict';
import JsonDB from 'node-json-db';
import fs from 'fs';
import path from 'path';
import electron from 'electron';
import Logger from './logger-util';
import fs = require('fs');
import path = require('path');
import electron = require('electron');
import Logger = require('./logger-util');
const remote =
process.type === 'renderer' ? electron.remote : electron;
@@ -14,49 +15,62 @@ const logger = new Logger({
});
/* To make the util runnable in both main and renderer process */
const {dialog, app} = remote;
const { dialog, app } = remote;
let instance: null | LinuxUpdateUtil = null;
let db: JsonDB;
class LinuxUpdateUtil {
db: JsonDB;
reloadDB();
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
export function getUpdateItem(key: string, defaultValue: unknown = null): any {
reloadDB();
const value = db.getData('/')[key];
if (value === undefined) {
setUpdateItem(key, defaultValue);
return defaultValue;
this.reloadDB();
return instance;
}
return value;
}
export function setUpdateItem(key: string, value: unknown): void {
db.push(`/${key}`, value, true);
reloadDB();
}
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDB();
}
function reloadDB(): void {
const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json');
try {
const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8');
JSON.parse(file);
} catch (error) {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
'Error saving update notifications.',
'We encountered an error while saving the update notifications.'
);
logger.error('Error while JSON parsing updates.json: ');
logger.error(error);
getUpdateItem(key: string, defaultValue: any = null): any {
this.reloadDB();
const value = this.db.getData('/')[key];
if (value === undefined) {
this.setUpdateItem(key, defaultValue);
return defaultValue;
} else {
return value;
}
}
db = new JsonDB(linuxUpdateJsonPath, true, true);
setUpdateItem(key: string, value: any): void {
this.db.push(`/${key}`, value, true);
this.reloadDB();
}
removeUpdateItem(key: string): void {
this.db.delete(`/${key}`);
this.reloadDB();
}
reloadDB(): void {
const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json');
try {
const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8');
JSON.parse(file);
} catch (err) {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
'Error saving update notifications.',
'We encountered an error while saving the update notifications.'
);
logger.error('Error while JSON parsing updates.json: ');
logger.error(err);
}
}
this.db = new JsonDB(linuxUpdateJsonPath, true, true);
}
}
export = new LinuxUpdateUtil();

View File

@@ -1,14 +1,19 @@
import {Console} from 'console'; // eslint-disable-line node/prefer-global/console
import {initSetUp} from './default-util';
import {sentryInit, captureException} from './sentry-util';
import { Console as NodeConsole } from 'console'; // eslint-disable-line node/prefer-global/console
import { initSetUp } from './default-util';
import { sentryInit, captureException } from './sentry-util';
import fs from 'fs';
import os from 'os';
import isDev from 'electron-is-dev';
import electron from 'electron';
import fs = require('fs');
import os = require('os');
import isDev = require('electron-is-dev');
import electron = require('electron');
// this interface adds [key: string]: any so
// we can do console[type] later on in the code
interface PatchedConsole extends Console {
[key: string]: any;
}
interface LoggerOptions {
timestamp?: true | (() => string);
timestamp?: any;
file?: string;
level?: boolean;
logInDevMode?: boolean;
@@ -24,9 +29,9 @@ if (process.type === 'renderer') {
// Report Errors to Sentry only if it is enabled in settings
// Gets the value of reportErrors from config-util for renderer process
// For main process, sentryInit() is handled in index.js
const {ipcRenderer} = electron;
const { ipcRenderer } = electron;
ipcRenderer.send('error-reporting');
ipcRenderer.on('error-reporting-val', (_event: Event, errorReporting: boolean) => {
ipcRenderer.on('error-reporting-val', (_event: any, errorReporting: boolean) => {
reportErrors = errorReporting;
if (reportErrors) {
sentryInit();
@@ -36,31 +41,23 @@ if (process.type === 'renderer') {
app = electron.app;
}
const browserConsole: PatchedConsole = console;
const logDir = `${app.getPath('userData')}/Logs`;
type Level = 'log' | 'debug' | 'info' | 'warn' | 'error';
const levels: Level[] = ['log', 'debug', 'info', 'warn', 'error'];
type LogMethod = (...args: unknown[]) => void;
export default class Logger {
log: LogMethod;
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
error: LogMethod;
nodeConsole: Console;
timestamp?: () => string;
class Logger {
nodeConsole: PatchedConsole;
timestamp: any; // TODO: TypeScript - Figure out how to make this work with string | Function.
level: boolean;
logInDevMode: boolean;
[key: string]: any;
constructor(options: LoggerOptions = {}) {
constructor(opts: LoggerOptions = {}) {
let {
timestamp = true,
file = 'console.log',
level = true,
logInDevMode = false
} = options;
} = opts;
file = `${logDir}/${file}`;
if (timestamp === true) {
@@ -74,8 +71,8 @@ export default class Logger {
process.nextTick(() => this.trimLog(file));
}
const fileStream = fs.createWriteStream(file, {flags: 'a'});
const nodeConsole = new Console(fileStream);
const fileStream = fs.createWriteStream(file, { flags: 'a' });
const nodeConsole = new NodeConsole(fileStream);
this.nodeConsole = nodeConsole;
this.timestamp = timestamp;
@@ -84,37 +81,42 @@ export default class Logger {
this.setUpConsole();
}
_log(type: Level, ...args: unknown[]): void {
_log(type: string, ...args: any[]): void {
const {
nodeConsole, timestamp, level, logInDevMode
} = this;
let nodeConsoleLog;
/* eslint-disable no-fallthrough */
switch (true) {
case typeof timestamp === 'function':
args.unshift(timestamp() + ' |\t');
case (level):
case (level !== false):
args.unshift(type.toUpperCase() + ' |');
case isDev || logInDevMode:
nodeConsole[type](...args);
nodeConsoleLog = nodeConsole[type] || nodeConsole.log;
nodeConsoleLog.apply(null, args); // eslint-disable-line prefer-spread
default: break;
}
/* eslint-enable no-fallthrough */
console[type](...args);
browserConsole[type].apply(null, args);
}
setUpConsole(): void {
for (const type of levels) {
for (const type in browserConsole) {
this.setupConsoleMethod(type);
}
}
setupConsoleMethod(type: Level): void {
this[type] = (...args: unknown[]) => this._log(type, ...args);
setupConsoleMethod(type: string): void {
this[type] = (...args: any[]) => {
const log = this._log.bind(this, type, ...args);
log();
};
}
getTimestamp(): string {
@@ -125,7 +127,7 @@ export default class Logger {
return timestamp;
}
reportSentry(err: unknown): void {
reportSentry(err: string): void {
if (reportErrors) {
captureException(err);
}
@@ -136,7 +138,6 @@ export default class Logger {
if (err) {
throw err;
}
const MAX_LOG_FILE_LINES = 500;
const logs = data.split(os.EOL);
const logLength = logs.length - 1;
@@ -150,3 +151,5 @@ export default class Logger {
});
}
}
export = Logger;

View File

@@ -0,0 +1,11 @@
// This util function returns the page params if they're present else returns null
export function isPageParams(): null | object {
let webpageParams = null;
try {
// eslint-disable-next-line no-undef, @typescript-eslint/camelcase
webpageParams = page_params;
} catch (_) {
webpageParams = null;
}
return webpageParams;
}

View File

@@ -1,122 +1,142 @@
import * as ConfigUtil from './config-util';
'use strict';
export interface ProxyRule {
import url = require('url');
import ConfigUtil = require('./config-util');
let instance: null | ProxyUtil = null;
interface ProxyRule {
hostname?: string;
port?: number;
}
// Return proxy to be used for a particular uri, to be used for request
export function getProxy(_uri: string): ProxyRule | void {
let uri;
try {
uri = new URL(_uri);
} catch (_) {
return;
class ProxyUtil {
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
return instance;
}
const proxyRules = ConfigUtil.getConfigItem('proxyRules', '').split(';');
// If SPS is on and system uses no proxy then request should not try to use proxy from
// environment. NO_PROXY = '*' makes request ignore all environment proxy variables.
if (proxyRules[0] === '') {
process.env.NO_PROXY = '*';
return;
// Return proxy to be used for a particular uri, to be used for request
getProxy(_uri: string): ProxyRule | void {
const parsedUri = url.parse(_uri);
if (parsedUri === null) {
return;
}
const uri = parsedUri;
const proxyRules = ConfigUtil.getConfigItem('proxyRules', '').split(';');
// If SPS is on and system uses no proxy then request should not try to use proxy from
// environment. NO_PROXY = '*' makes request ignore all environment proxy variables.
if (proxyRules[0] === '') {
process.env.NO_PROXY = '*';
return;
}
const proxyRule: any = {};
if (uri.protocol === 'http:') {
proxyRules.forEach((proxy: string) => {
if (proxy.includes('http=')) {
proxyRule.hostname = proxy.split('http=')[1].trim().split(':')[0];
proxyRule.port = proxy.split('http=')[1].trim().split(':')[1];
}
});
return proxyRule;
}
if (uri.protocol === 'https:') {
proxyRules.forEach((proxy: string) => {
if (proxy.includes('https=')) {
proxyRule.hostname = proxy.split('https=')[1].trim().split(':')[0];
proxyRule.port = proxy.split('https=')[1].trim().split(':')[1];
}
});
return proxyRule;
}
}
const proxyRule: any = {};
if (uri.protocol === 'http:') {
proxyRules.forEach((proxy: string) => {
if (proxy.includes('http=')) {
proxyRule.hostname = proxy.split('http=')[1].trim().split(':')[0];
proxyRule.port = proxy.split('http=')[1].trim().split(':')[1];
// TODO: Refactor to async function
resolveSystemProxy(mainWindow: Electron.BrowserWindow): void {
const page = mainWindow.webContents;
const ses = page.session;
const resolveProxyUrl = 'www.example.com';
// Check HTTP Proxy
const httpProxy = new Promise(resolve => {
ses.resolveProxy('http://' + resolveProxyUrl, (proxy: string) => {
let httpString = '';
if (proxy !== 'DIRECT') {
// in case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpString = 'http=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(httpString);
});
});
// Check HTTPS Proxy
const httpsProxy = new Promise(resolve => {
ses.resolveProxy('https://' + resolveProxyUrl, (proxy: string) => {
let httpsString = '';
if (proxy !== 'DIRECT' || proxy.includes('HTTPS')) {
// in case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpsString += 'https=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(httpsString);
});
});
// Check FTP Proxy
const ftpProxy = new Promise(resolve => {
ses.resolveProxy('ftp://' + resolveProxyUrl, (proxy: string) => {
let ftpString = '';
if (proxy !== 'DIRECT') {
if (proxy.includes('PROXY')) {
ftpString += 'ftp=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(ftpString);
});
});
// Check SOCKS Proxy
const socksProxy = new Promise(resolve => {
ses.resolveProxy('socks4://' + resolveProxyUrl, (proxy: string) => {
let socksString = '';
if (proxy !== 'DIRECT') {
if (proxy.includes('SOCKS5')) {
socksString += 'socks=' + proxy.split('SOCKS5')[1] + ';';
} else if (proxy.includes('SOCKS4')) {
socksString += 'socks=' + proxy.split('SOCKS4')[1] + ';';
} else if (proxy.includes('PROXY')) {
socksString += 'socks=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(socksString);
});
});
Promise.all([httpProxy, httpsProxy, ftpProxy, socksProxy]).then(values => {
let proxyString = '';
values.forEach(proxy => {
proxyString += proxy;
});
ConfigUtil.setConfigItem('systemProxyRules', proxyString);
const useSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (useSystemProxy) {
ConfigUtil.setConfigItem('proxyRules', proxyString);
}
});
return proxyRule;
}
if (uri.protocol === 'https:') {
proxyRules.forEach((proxy: string) => {
if (proxy.includes('https=')) {
proxyRule.hostname = proxy.split('https=')[1].trim().split(':')[0];
proxyRule.port = proxy.split('https=')[1].trim().split(':')[1];
}
});
return proxyRule;
}
}
// TODO: Refactor to async function
export async function resolveSystemProxy(mainWindow: Electron.BrowserWindow): Promise<void> {
const page = mainWindow.webContents;
const ses = page.session;
const resolveProxyUrl = 'www.example.com';
// Check HTTP Proxy
const httpProxy = (async () => {
const proxy = await ses.resolveProxy('http://' + resolveProxyUrl);
let httpString = '';
if (proxy !== 'DIRECT') {
// In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpString = 'http=' + proxy.split('PROXY')[1] + ';';
}
}
return httpString;
})();
// Check HTTPS Proxy
const httpsProxy = (async () => {
const proxy = await ses.resolveProxy('https://' + resolveProxyUrl);
let httpsString = '';
if (proxy !== 'DIRECT' || proxy.includes('HTTPS')) {
// In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpsString += 'https=' + proxy.split('PROXY')[1] + ';';
}
}
return httpsString;
})();
// Check FTP Proxy
const ftpProxy = (async () => {
const proxy = await ses.resolveProxy('ftp://' + resolveProxyUrl);
let ftpString = '';
if (proxy !== 'DIRECT') {
if (proxy.includes('PROXY')) {
ftpString += 'ftp=' + proxy.split('PROXY')[1] + ';';
}
}
return ftpString;
})();
// Check SOCKS Proxy
const socksProxy = (async () => {
const proxy = await ses.resolveProxy('socks4://' + resolveProxyUrl);
let socksString = '';
if (proxy !== 'DIRECT') {
if (proxy.includes('SOCKS5')) {
socksString += 'socks=' + proxy.split('SOCKS5')[1] + ';';
} else if (proxy.includes('SOCKS4')) {
socksString += 'socks=' + proxy.split('SOCKS4')[1] + ';';
} else if (proxy.includes('PROXY')) {
socksString += 'socks=' + proxy.split('PROXY')[1] + ';';
}
}
return socksString;
})();
const values = await Promise.all([httpProxy, httpsProxy, ftpProxy, socksProxy]);
let proxyString = '';
values.forEach(proxy => {
proxyString += proxy;
});
ConfigUtil.setConfigItem('systemProxyRules', proxyString);
const useSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (useSystemProxy) {
ConfigUtil.setConfigItem('proxyRules', proxyString);
}
}
export = new ProxyUtil();

View File

@@ -1,24 +1,26 @@
import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron';
import type WebView from '../components/webview';
import backoff from 'backoff';
import request from 'request';
import Logger from './logger-util';
import * as RequestUtil from './request-util';
import * as DomainUtil from './domain-util';
import backoff = require('backoff');
import request = require('request');
import Logger = require('./logger-util');
import RequestUtil = require('./request-util');
import DomainUtil = require('./domain-util');
const logger = new Logger({
file: 'domain-util.log',
file: `domain-util.log`,
timestamp: true
});
export default class ReconnectUtil {
webview: WebView;
class ReconnectUtil {
// TODO: TypeScript - Figure out how to annotate webview
// it should be WebView; maybe make it a generic so we can
// pass the class from main.ts
webview: any;
url: string;
alreadyReloaded: boolean;
fibonacciBackoff: backoff.Backoff;
fibonacciBackoff: any;
constructor(webview: WebView) {
constructor(webview: any) {
this.webview = webview;
this.url = webview.props.url;
this.alreadyReloaded = false;
@@ -32,61 +34,69 @@ export default class ReconnectUtil {
});
}
async isOnline(): Promise<boolean> {
isOnline(): Promise<boolean> {
return new Promise(resolve => {
try {
const ignoreCerts = DomainUtil.shouldIgnoreCerts(this.url);
if (ignoreCerts === null) {
return;
}
request(
{
url: `${this.url}/static/favicon.ico`,
...RequestUtil.requestOptions(this.url, ignoreCerts)
},
(error: Error, response: request.Response) => {
(error: Error, response: any) => {
const isValidResponse =
!error && response.statusCode >= 200 && response.statusCode < 400;
resolve(isValidResponse);
}
);
} catch (error) {
logger.log(error);
} catch (err) {
logger.log(err);
}
});
}
pollInternetAndReload(): void {
this.fibonacciBackoff.backoff();
this.fibonacciBackoff.on('ready', async () => {
if (await this._checkAndReload()) {
this.fibonacciBackoff.reset();
} else {
this.fibonacciBackoff.backoff();
}
this.fibonacciBackoff.on('ready', () => {
this._checkAndReload().then(status => {
if (status) {
this.fibonacciBackoff.reset();
} else {
this.fibonacciBackoff.backoff();
}
});
});
}
async _checkAndReload(): Promise<boolean> {
if (this.alreadyReloaded) {
return true;
}
// TODO: Make this a async function
_checkAndReload(): Promise<boolean> {
return new Promise(resolve => {
if (!this.alreadyReloaded) { // eslint-disable-line no-negated-condition
this.isOnline()
.then((online: boolean) => {
if (online) {
ipcRenderer.send('forward-message', 'reload-viewer');
logger.log('You\'re back online.');
return resolve(true);
}
if (await this.isOnline()) {
ipcRenderer.send('forward-message', 'reload-viewer');
logger.log('You\'re back online.');
return true;
}
logger.log('There is no internet connection, try checking network cables, modem and router.');
const errorMessageHolder = document.querySelector('#description');
if (errorMessageHolder) {
errorMessageHolder.innerHTML = `
<div>Your internet connection doesn't seem to work properly!</div>
<div>Verify that it works and then click try again.</div>`;
}
return false;
logger.log('There is no internet connection, try checking network cables, modem and router.');
const errMsgHolder = document.querySelector('#description');
if (errMsgHolder) {
errMsgHolder.innerHTML = `
<div>Your internet connection doesn't seem to work properly!</div>
<div>Verify that it works and then click try again.</div>`;
}
return resolve(false);
});
} else {
return resolve(true);
}
});
}
}
export = ReconnectUtil;

View File

@@ -1,71 +1,87 @@
import {remote} from 'electron';
import { remote } from 'electron';
import fs from 'fs';
import path from 'path';
import * as ConfigUtil from './config-util';
import Logger from './logger-util';
import * as ProxyUtil from './proxy-util';
import * as CertificateUtil from './certificate-util';
import * as SystemUtil from './system-util';
import fs = require('fs');
import path = require('path');
import ConfigUtil = require('./config-util');
import Logger = require('./logger-util');
import ProxyUtil = require('./proxy-util');
import CertificateUtil = require('./certificate-util');
import SystemUtil = require('./system-util');
const {app} = remote;
const { app } = remote;
const logger = new Logger({
file: 'request-util.log',
file: `request-util.log`,
timestamp: true
});
let instance: null | RequestUtil = null;
// TODO: TypeScript - Use ProxyRule for the proxy property
// we can do this now since we use export = ProxyUtil syntax
interface RequestUtilResponse {
ca: string;
proxy: string | void | ProxyUtil.ProxyRule;
proxy: string | void | object;
ecdhCurve: 'auto';
headers: { 'User-Agent': string };
rejectUnauthorized: boolean;
}
// ignoreCerts parameter helps in fetching server icon and
// other server details when user chooses to ignore certificate warnings
export function requestOptions(domain: string, ignoreCerts: boolean): RequestUtilResponse {
domain = formatUrl(domain);
const certificate = CertificateUtil.getCertificate(
encodeURIComponent(domain)
);
class RequestUtil {
constructor() {
if (!instance) {
instance = this;
}
let certificateFile = null;
if (certificate?.includes('/')) {
// Certificate saved using old app version
certificateFile = certificate;
} else if (certificate) {
certificateFile = path.join(`${app.getPath('userData')}/certificates`, certificate);
return instance;
}
let certificateLocation = '';
if (certificate) {
// To handle case where certificate has been moved from the location in certificates.json
try {
certificateLocation = fs.readFileSync(certificateFile, 'utf8');
} catch (error) {
logger.warn('Error while trying to get certificate:', error);
// ignoreCerts parameter helps in fetching server icon and
// other server details when user chooses to ignore certificate warnings
requestOptions(domain: string, ignoreCerts: boolean): RequestUtilResponse {
domain = this.formatUrl(domain);
const certificate = CertificateUtil.getCertificate(
encodeURIComponent(domain)
);
let certificateFile = null;
if (certificate && certificate.includes('/')) {
// certificate saved using old app version
certificateFile = certificate;
} else if (certificate) {
certificateFile = path.join(`${app.getPath('userData')}/certificates`, certificate);
}
let certificateLocation = '';
if (certificate) {
// To handle case where certificate has been moved from the location in certificates.json
try {
certificateLocation = fs.readFileSync(certificateFile, 'utf8');
} catch (err) {
logger.warn(`Error while trying to get certificate: ${err}`);
}
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
// If certificate for the domain exists add it as a ca key in the request's parameter else consider only domain as the parameter for request
// Add proxy as a parameter if it is being used.
return {
ca: certificateLocation ? certificateLocation : '',
proxy: proxyEnabled ? ProxyUtil.getProxy(domain) : '',
ecdhCurve: 'auto',
headers: { 'User-Agent': SystemUtil.getUserAgent() },
rejectUnauthorized: !ignoreCerts
};
}
formatUrl(domain: string): string {
const hasPrefix = domain.startsWith('http', 0);
if (hasPrefix) {
return domain;
} else {
return domain.includes('localhost:') ? `http://${domain}` : `https://${domain}`;
}
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
// If certificate for the domain exists add it as a ca key in the request's parameter else consider only domain as the parameter for request
// Add proxy as a parameter if it is being used.
return {
ca: certificateLocation ? certificateLocation : '',
proxy: proxyEnabled ? ProxyUtil.getProxy(domain) : '',
ecdhCurve: 'auto',
headers: {'User-Agent': SystemUtil.getUserAgent()},
rejectUnauthorized: !ignoreCerts
};
}
function formatUrl(domain: string): string {
const hasPrefix = domain.startsWith('http', 0);
if (hasPrefix) {
return domain;
}
return domain.includes('localhost:') ? `http://${domain}` : `https://${domain}`;
}
const requestUtil = new RequestUtil();
export = requestUtil;

View File

@@ -1,18 +1,21 @@
import {init} from '@sentry/electron';
import { init } from '@sentry/electron';
import isDev from 'electron-is-dev';
import isDev = require('electron-is-dev');
import path = require('path');
import dotenv = require('dotenv');
dotenv.config({ path: path.resolve(__dirname, '/../../../../.env') });
export const sentryInit = (): void => {
if (!isDev) {
init({
dsn: 'https://628dc2f2864243a08ead72e63f94c7b1@sentry.io/204668',
dsn: process.env.SENTRY_DSN,
// We should ignore this error since it's harmless and we know the reason behind this
// This error mainly comes from the console logs.
// This is a temp solution until Sentry supports disabling the console logs
ignoreErrors: ['does not appear to be a valid Zulip server']
/// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second
// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second
});
}
};
export {captureException} from '@sentry/electron';
export { captureException } from '@sentry/electron';

View File

@@ -1,39 +1,64 @@
import {ipcRenderer} from 'electron';
'use strict';
import { remote } from 'electron';
import os from 'os';
import os = require('os');
import ConfigUtil = require('./config-util');
export const connectivityERR: string[] = [
'ERR_INTERNET_DISCONNECTED',
'ERR_PROXY_CONNECTION_FAILED',
'ERR_CONNECTION_RESET',
'ERR_NOT_CONNECTED',
'ERR_NAME_NOT_RESOLVED',
'ERR_NETWORK_CHANGED'
];
const { app } = remote;
let instance: null | SystemUtil = null;
const userAgent = ipcRenderer.sendSync('fetch-user-agent');
class SystemUtil {
connectivityERR: string[];
export function getOS(): string {
const platform = os.platform();
if (platform === 'darwin') {
return 'Mac';
}
userAgent: string | null;
if (platform === 'linux') {
return 'Linux';
}
if (platform === 'win32') {
if (Number.parseFloat(os.release()) < 6.2) {
return 'Windows 7';
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
return 'Windows 10';
this.connectivityERR = [
'ERR_INTERNET_DISCONNECTED',
'ERR_PROXY_CONNECTION_FAILED',
'ERR_CONNECTION_RESET',
'ERR_NOT_CONNECTED',
'ERR_NAME_NOT_RESOLVED',
'ERR_NETWORK_CHANGED'
];
this.userAgent = null;
return instance;
}
return '';
getOS(): string {
const platform = os.platform();
if (platform === 'darwin') {
return 'Mac';
} else if (platform === 'linux') {
return 'Linux';
} else if (platform === 'win32') {
if (parseFloat(os.release()) < 6.2) {
return 'Windows 7';
} else {
return 'Windows 10';
}
} else {
return '';
}
}
setUserAgent(webViewUserAgent: string): void {
this.userAgent = `ZulipElectron/${app.getVersion()} ${webViewUserAgent}`;
}
getUserAgent(): string | null {
if (!this.userAgent) {
this.setUserAgent(ConfigUtil.getConfigItem('userAgent', null));
}
return this.userAgent;
}
}
export function getUserAgent(): string {
return userAgent;
}
export = new SystemUtil();

View File

@@ -1,15 +1,35 @@
import path from 'path';
import i18n from 'i18n';
import * as ConfigUtil from './config-util';
'use strict';
i18n.configure({
directory: path.join(__dirname, '../../../translations/')
});
import path = require('path');
import electron = require('electron');
import i18n = require('i18n');
/* Fetches the current appLocale from settings.json */
const appLocale = ConfigUtil.getConfigItem('appLanguage');
let instance: TranslationUtil = null;
let app: Electron.App = null;
/* If no locale present in the json, en is set default */
export function __(phrase: string): string {
return i18n.__({phrase, locale: appLocale ? appLocale : 'en'});
/* To make the util runnable in both main and renderer process */
if (process.type === 'renderer') {
app = electron.remote.app;
} else {
app = electron.app;
}
class TranslationUtil {
constructor() {
if (instance) {
return this;
}
instance = this;
i18n.configure({
directory: path.join(__dirname, '../../../translations/'),
register: this
});
}
__(phrase: string): string {
return i18n.__({ phrase, locale: app.getLocale() ? app.getLocale() : 'en' });
}
}
export = new TranslationUtil();

View File

@@ -23,4 +23,7 @@
</div>
</div>
</body>
<script>var exports = {};</script>
<script src="js/preload.js"></script>
<script>require('./js/shared/preventdrag.js')</script>
</html>

Binary file not shown.

View File

@@ -3,47 +3,50 @@ interface DialogBoxError {
content: string;
}
export function invalidZulipServerError(domain: string): string {
return `${domain} does not appear to be a valid Zulip server. Make sure that
\n • You can connect to that URL in a web browser.\
\n • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.\
\n • It's a Zulip server. (The oldest supported version is 1.6).\
\n • The server has a valid certificate. (You can add custom certificates in Settings > Organizations). \
\n • The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide -
\n https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
}
export function noOrgsError(domain: string): string {
return `${domain} does not have any organizations added.\
\nPlease contact your server administrator.`;
}
export function certErrorMessage(domain: string, error: string): string {
return `Do you trust certificate from ${domain}? \n ${error}`;
}
export function certErrorDetail(): string {
return `The organization you're connecting to is either someone impersonating the Zulip server you entered, or the server you're trying to connect to is configured in an insecure way.
\nIf you have a valid certificate please add it from Settings>Organizations and try to add the organization again.
\nUnless you have a good reason to believe otherwise, you should not proceed.
\nYou can click here if you'd like to proceed with the connection.`;
}
export function enterpriseOrgError(length: number, domains: string[]): DialogBoxError {
let domainList = '';
for (const domain of domains) {
domainList += `${domain}\n`;
class Messages {
invalidZulipServerError(domain: string): string {
return `${domain} does not appear to be a valid Zulip server. Make sure that
\n • You can connect to that URL in a web browser.\
\n • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.\
\n • It's a Zulip server. (The oldest supported version is 1.6).\
\n • The server has a valid certificate. (You can add custom certificates in Settings > Organizations). \
\n • The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide -
\n https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
}
return {
title: `Could not add the following ${length === 1 ? 'organization' : 'organizations'}`,
content: `${domainList}\nPlease contact your system administrator.`
};
noOrgsError(domain: string): string {
return `${domain} does not have any organizations added.\
\nPlease contact your server administrator.`;
}
certErrorMessage(domain: string, error: string): string {
return `Do you trust certificate from ${domain}? \n ${error}`;
}
certErrorDetail(): string {
return `The organization you're connecting to is either someone impersonating the Zulip server you entered, or the server you're trying to connect to is configured in an insecure way.
\nIf you have a valid certificate please add it from Settings>Organizations and try to add the organization again.
\nUnless you have a good reason to believe otherwise, you should not proceed.
\nYou can click here if you'd like to proceed with the connection.`;
}
enterpriseOrgError(length: number, domains: string[]): DialogBoxError {
let domainList = '';
for (const domain of domains) {
domainList += `${domain}\n`;
}
return {
title: `Could not add the following ${length === 1 ? `organization` : `organizations`}`,
content: `${domainList}\nPlease contact your system administrator.`
};
}
orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: `Please contact your system administrator.`
};
}
}
export function orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: 'Please contact your system administrator.'
};
}
export = new Messages();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 900 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -114,9 +114,5 @@
"Emoji & Symbols": "Emoji & Symbols",
"View Shortcuts": "View Shortcuts",
"Enter Full Screen": "Enter Full Screen",
"History Shortcuts": "History Shortcuts",
"Quit when the window is closed": "Quit when the window is closed",
"File": "File",
"Network and Proxy Settings": "Network and Proxy Settings",
"Ask where to save files before downloading": "Ask where to save files before downloading"
"History Shortcuts": "History Shortcuts"
}

View File

@@ -104,16 +104,5 @@
"Edit Shortcuts": "Edit Shortcuts",
"View Shortcuts": "View Shortcuts",
"History Shortcuts": "History Shortcuts",
"Window Shortcuts": "Window Shortcuts",
"Quit when the window is closed": "Quit when the window is closed",
"Force social login in app instead of browser": "Force social login in app instead of browser",
"Ask where to save files before downloading": "Ask where to save files before downloading",
"Add a Zulip organization": "Add a Zulip organization",
"Connect": "Connect",
"OR": "OR",
"Create a new organization": "Create a new organization",
"Network and Proxy Settings": "Network and Proxy Settings",
"YES": "YES",
"NO": "NO",
"Are you sure you want to disconnect this organization?": "Are you sure you want to disconnect this organization?"
}
"Window Shortcuts": "Window Shortcuts"
}

View File

@@ -114,6 +114,5 @@
"Zulip Help": "Zulip Help",
"keyboard shortcuts": "keyboard shortcuts",
"script": "script",
"Quit when the window is closed": "Quit when the window is closed",
"Ask where to save files before downloading": "Ask where to save files before downloading"
"Quit when the window is closed": "Quit when the window is closed"
}

View File

@@ -1,23 +0,0 @@
{
"bg": "Bulgarian",
"cs": "Czech",
"de": "Deutsch",
"en-GB": "English (UK)",
"en-US": "English (US)",
"en": "English",
"es": "Español",
"fr": "French",
"hi": "Hindi",
"hu": "Hungarian",
"id": "Indonesian",
"ja": "Japanese",
"ko": "Korean",
"ml": "Malayalam",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"ru": "Russian",
"sr": "Macedonian",
"ta": "Tamil",
"tr": "Turkish"
}

View File

@@ -5,16 +5,17 @@ platform:
os: Previous Visual Studio 2015
cache:
- node_modules -> appveyor.yml
- node_modules
install:
- ps: Install-Product node 10 x64
- ps: Install-Product node 8 x64
- git reset --hard HEAD
- npm install npm -g
- node --version
- npm --version
- python --version
- npm ci
- npm install
- npm install -g gulp
build: off

Binary file not shown.

View File

@@ -2,94 +2,6 @@
All notable changes to the Zulip desktop app are documented in this file.
### v5.2.0 --2020-05-04
**Security fixes**:
* CVE-2020-12637: Do not ignore certificate errors in webviews unless the (unsupported, deprecated) `ignoreCerts` option is enabled.
**Fixes**:
* Avoid opening the file chooser dialog twice when downloading a file.
**New features**:
* Provide clipboard decryption helper for use in new social login flow.
**Dependencies**:
* Upgrade all dependencies, including Electron 8.2.5.
### v5.1.0 --2020-04-29
**Fixes**:
* macOS: If the app is in foreground, the app will no longer hide upon clicking on dock again.
* Synchronise debian scripts with electron-builder 22.4.1, thus fixing SUID sandbox binary issues.
* Dock icon on macOS used to be larger than the other applications, which is now updated to the appropriate size.
* Upon catching error in updating the server icon, the app will log the error and make a sentry report instead of triggering user-facing network error
**New features**:
* User can now set application language without changing the language on their operating system.
**Dependencies**:
* Upgrade all dependencies, including Electron 8.2.3.
### v5.0.0 --2020-03-30
**Security fixes**:
* CVE-2020-10856: Enable Electron context isolation. (Reported by Matt Austin.)
* CVE-2020-10857: Fix unsafe use of `shell.openExternal`/`shell.openItem`. (Reported by Matt Austin.)
* Downloaded files will no longer be opened directly; the previous option to show downloaded files in the file manager is now always on.
* CVE-2020-10858: Add permission request handler to guard against audio/video recording by a malicious server. (Reported by Matt Austin.)
**New features**:
* Add an option to prompt for the location to save each downloaded file.
**Fixes**:
* Fix automatic launching at startup.
* Fix Undo and Redo functionality on macOS.
**Dependencies**:
* Upgrade all dependencies, including Electron 8.0.3.
* Remove `assert`, `cp-file`, `dotenv`, `electron-debug`, and `wurl`.
**Deprecations**:
* Since Electron upstream has discontinued support for 32-bit Linux, we will only provide 32-bit Linux builds on a best effort basis, and they will likely be removed in a future release.
### v4.0.3 --2020-02-29
**Security fixes**:
* CVE-2020-9443: Do not disable web security in the Electron webview. (Reported by Matt Austin.)
### v4.0.2-beta --2019-11-13
**New features**:
* Add support for MSI installers.
* Show badge count on Linux.
* Sync system presence info to web app.
* Add option to open notification settings from the context menu of organization icons in the sidebar.
* Tackle network issues with each Zulip organization independently.
* Add option to quit on closing the window.
* Make certificate location dynamic.
* Add option to specify network settings when adding a new organization.
**Enhancements**:
* Load last active tab before others, speeding up user experience and eliminating flashing of server icons.
* Improve UX for notification settings.
* Set User-Agent from the main process for communication with the Zulip API.
* Add SSL troubleshooting guide in error message when adding an organization fails.
**Fixes**:
* Fix translations for `ru` locales.
* Fix trailing brackets in settings page.
* Fix broken icon issue faced by the snap package on Linux.
* Reactivate `network.js` script.
* Enable notarization for macOS Catalina.
**Documentation**:
* Document enterprise configuration features.
* Update the Electron tutorial guide.
* Explicitly address where to report bugs in `README.md`.
* Fix typo in the link to server/webapp repository in `README.md`.
* Add documentation for translation.
### v4.0.0 --2019-08-08
**New features**:

View File

@@ -9,7 +9,7 @@ To build and run the app from source, you'll need the following:
* [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
* Use our [Git Guide](https://zulip.readthedocs.io/en/latest/git/setup.html) to get started with Git and GitHub.
* [Node.js](https://nodejs.org) >= v10.16.3
* [Node.js](https://nodejs.org) >= v6.9.0
* [NPM](https://www.npmjs.com/get-npm) and
[node-gyp](https://github.com/nodejs/node-gyp#installation),
if they don't come bundled with your Node.js installation
@@ -111,4 +111,4 @@ operating system you're running on:
To generate all three types of files, you will need all three operating
systems.
The output distributable packages appear in the `dist/` directory.
The output distributable packages appear in the `dist/` directory.

View File

@@ -5,14 +5,14 @@ const electron = require('electron-connect').server.create({
});
const tape = require('gulp-tape');
const tapColorize = require('tap-colorize');
const ts = require('gulp-typescript');
const tsProject = ts.createProject('tsconfig.json');
const ts = require("gulp-typescript");
const tsProject = ts.createProject("tsconfig.json");
const glob = require('glob');
const {execSync} = require('child_process');
const { execSync } = require('child_process');
const baseFilePattern = 'app/+(main|renderer)/**/*';
const globOptions = {cwd: __dirname};
const globOptions = { cwd: __dirname };
const jsFiles = glob.sync(baseFilePattern + '.js', globOptions);
const tsFiles = glob.sync(baseFilePattern + '.ts', globOptions);
if (jsFiles.length !== tsFiles.length) {
@@ -21,18 +21,18 @@ if (jsFiles.length !== tsFiles.length) {
execSync(`${npx} tsc`);
}
gulp.task('compile', () => {
return tsProject.src()
.pipe(tsProject())
.js.pipe(gulp.dest('app'));
gulp.task("compile", function () {
return tsProject.src()
.pipe(tsProject())
.js.pipe(gulp.dest("app"));
});
gulp.task('dev', () => {
// Start browser process
// Start browser process
electron.start();
// Restart browser process
// Restart browser process
gulp.watch('app/main/*.ts', gulp.series('compile', 'restart:browser'));
// Reload renderer process
// Reload renderer process
gulp.watch('app/renderer/css/*.css', gulp.series('reload:renderer'));
gulp.watch('app/renderer/*.html', gulp.series('reload:renderer'));
gulp.watch('app/renderer/js/**/*.ts', gulp.series('compile', 'reload:renderer'));
@@ -45,16 +45,16 @@ gulp.task('restart:browser', done => {
});
gulp.task('reload:renderer', done => {
// Reload renderer process
// Reload renderer process
electron.reload();
done();
});
gulp.task('test-e2e', () => {
return gulp.src('tests/*.js')
.pipe(tape({
reporter: tapColorize()
}));
.pipe(tape({
reporter: tapColorize()
}));
});
gulp.task('default', gulp.parallel('dev', 'test-e2e'));

13132
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "5.2.0",
"version": "4.0.3",
"main": "./app/main",
"description": "Zulip Desktop App",
"license": "Apache-2.0",
@@ -18,24 +18,25 @@
"url": "https://github.com/zulip/zulip-desktop/issues"
},
"engines": {
"node": ">=10.0.0"
"node": ">=6.0.0"
},
"scripts": {
"start": "tsc && electron .",
"clean-ts-files": "git clean app/*.js -e node_modules -xf",
"start": "node tools/run-dev",
"clean-ts-files": "git clean app/*.js -xf",
"watch-ts": "tsc -w",
"reinstall": "node ./tools/reinstall-node-modules.js",
"postinstall": "electron-builder install-app-deps",
"lint-css": "stylelint app/renderer/css/*.css",
"lint-html": "./node_modules/.bin/htmlhint \"app/renderer/*.html\" ",
"lint-js": "xo",
"test": "tsc --noEmit && npm run lint-html && npm run lint-css && npm run lint-js",
"test": "npm run lint-html && npm run lint-css && npm run lint-js",
"test-e2e": "gulp test-e2e",
"compile": "gulp compile",
"dev": "gulp dev && npm test",
"pack": "tsc && electron-builder --dir",
"dist": "tsc && electron-builder",
"mas": "tsc && electron-builder --mac mas",
"travis": "cd ./scripts && ./travis-build-test.sh",
"build-locales": "node tools/locale-helper"
},
"pre-commit": [
@@ -49,8 +50,9 @@
"**/*.node"
],
"files": [
"app/**/*",
"!**/node_modules/cld/deps/cld"
"**/*",
"!docs${/*}",
"!node_modules/@paulcbetts/cld/deps/cld${/*}"
],
"copyright": "©2019 Kandra Labs, Inc.",
"mac": {
@@ -141,106 +143,97 @@
"Desktop app",
"InstantMessaging"
],
"dependencies": {
"@electron-elements/send-feedback": "^1.0.8",
"@sentry/electron": "^1.3.0",
"adm-zip": "^0.4.14",
"auto-launch": "^5.0.5",
"backoff": "^2.5.0",
"electron-is-dev": "^1.2.0",
"electron-log": "^4.1.2",
"electron-spellchecker": "^2.2.1",
"electron-updater": "^4.3.1",
"electron-window-state": "^5.0.3",
"escape-html": "^1.0.3",
"fs-extra": "^9.0.0",
"i18n": "^0.9.1",
"node-json-db": "^1.1.0",
"request": "^2.88.2",
"rxjs": "^5.5.12",
"semver": "^7.3.2"
},
"optionalDependencies": {
"node-mac-notifier": "^1.1.0"
},
"devDependencies": {
"@types/adm-zip": "^0.4.33",
"@types/auto-launch": "^5.0.1",
"@types/backoff": "^2.5.1",
"@types/electron-spellchecker": "^1.1.2",
"@types/escape-html": "0.0.20",
"@types/fs-extra": "^8.1.0",
"@types/i18n": "^0.8.6",
"@types/node": "^13.13.4",
"@types/request": "^2.48.4",
"@types/requestidlecallback": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^2.30.0",
"@vitalets/google-translate-api": "^3.0.0",
"devtron": "^1.4.0",
"dotenv": "^8.2.0",
"electron": "^8.2.5",
"electron-builder": "^22.6.0",
"electron-connect": "^0.6.3",
"electron-notarize": "^0.3.0",
"eslint-config-xo-typescript": "^0.28.0",
"glob": "^7.1.6",
"gulp": "^4.0.2",
"gulp-tape": "^1.0.0",
"gulp-typescript": "^6.0.0-alpha.1",
"htmlhint": "^0.11.0",
"nodemon": "^2.0.3",
"pre-commit": "^1.2.2",
"rimraf": "^3.0.2",
"spectron": "^10.0.1",
"stylelint": "^13.3.3",
"tap-colorize": "^1.2.0",
"tape": "^5.0.0",
"typescript": "^3.8.3",
"xo": "^0.30.0"
"@types/adm-zip": "^0.4.32",
"@types/dotenv": "6.1.1",
"@typescript-eslint/eslint-plugin": "1.10.2",
"@typescript-eslint/parser": "1.10.2",
"@vitalets/google-translate-api": "2.8.0",
"assert": "1.4.1",
"cp-file": "5.0.0",
"devtron": "1.4.0",
"electron": "3.1.10",
"electron-builder": "20.43.0",
"electron-connect": "0.6.2",
"electron-debug": "1.4.0",
"electron-notarize": "0.2.0",
"eslint-config-xo-typescript": "0.14.0",
"fs-extra": "8.1.0",
"gulp": "4.0.0",
"gulp-tape": "0.0.9",
"gulp-typescript": "5.0.1",
"htmlhint": "0.11.0",
"is-ci": "1.0.10",
"nodemon": "1.14.11",
"pre-commit": "1.2.2",
"spectron": "5.0.0",
"stylelint": "9.10.1",
"tap-colorize": "1.2.0",
"tape": "4.8.0",
"typescript": "3.5.2",
"xo": "0.24.0"
},
"xo": {
"rules": {
"@typescript-eslint/no-dynamic-delete": "off",
"@typescript-eslint/prefer-readonly-parameter-types": "off",
"import/unambiguous": "error",
"max-lines": [
"warn",
{
"max": 900,
"skipBlankLines": true,
"skipComments": true
}
],
"no-warning-comments": "off",
"strict": "error"
},
"envs": [
"node",
"browser"
"extends": "xo-typescript",
"extensions": [
"ts"
],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"globalReturn": true,
"modules": true
}
},
"esnext": true,
"overrides": [
{
"files": [
"app/renderer/js/injected.ts",
"gulpfile.js",
"scripts/notarize.js",
"tests/**/*.js",
"tools/locale-helper/index.js",
"tools/reinstall-node-modules.js"
],
"parserOptions": {
"sourceType": "script"
}
},
{
"files": [
"**/*.d.ts"
],
"files": "app/**/*.ts",
"rules": {
"import/unambiguous": "off"
"max-lines": [
"warn",
{
"max": 800,
"skipBlankLines": true,
"skipComments": true
}
],
"no-warning-comments": 0,
"object-curly-spacing": 0,
"capitalized-comments": 0,
"no-else-return": 0,
"no-path-concat": 0,
"no-alert": 0,
"guard-for-in": 0,
"prefer-promise-reject-errors": 0,
"import/no-unresolved": 0,
"import/no-extraneous-dependencies": 0,
"no-prototype-builtins": 0,
"lines-between-class-members": 0,
"padding-line-between-statements": 0,
"quotes": 0,
"unicorn/catch-error-name": 0,
"unicorn/no-console-spaces": 0,
"node/no-deprecated-api": 0,
"@typescript-eslint/member-ordering": "never",
"@typescript-eslint/restrict-plus-operands": "never",
"import/default": 0,
"@typescript-eslint/no-unused-vars": 0
}
}
],
"ignore": [
"tests/*.js",
"tools/locale-helper/*.js",
"*/**/*.js",
"*.js",
"typings.d.ts"
],
"envs": [
"node",
"browser",
"mocha"
]
}
}
}

View File

@@ -3,13 +3,8 @@
# This script runs when user install the debian package
# Link to the binary
ln -sf '/opt/${productFilename}/${executable}' '/usr/bin/${executable}'
# SUID chrome-sandbox for Electron 5+
chmod 4755 '/opt/${productFilename}/chrome-sandbox' || true
update-mime-database /usr/share/mime || true
update-desktop-database /usr/share/applications || true
ln -sf '/opt/${productFilename}/${executable}' '/usr/local/bin/${executable}';
echo 'Successfully added /opt/${productFilename}/${executable} to /usr/local/bin/${executable}'
# Install apt repository source list if it does not exist
if ! grep ^ /etc/apt/sources.list /etc/apt/sources.list.d/* | grep zulip.list; then

View File

@@ -31,4 +31,5 @@ if [ -d $appDirectory ]; then
fi
# Delete the link to the binary
rm -f '/usr/bin/${executable}'
echo 'Removing binary link'
sudo rm -f '/usr/local/bin/${executable}';

View File

@@ -1,24 +1,22 @@
'use strict';
const path = require('path');
const dotenv = require('dotenv');
dotenv.config({path: path.join(__dirname, '/../.env')});
dotenv.config({ path: path.join(__dirname, '/../.env') });
const {notarize} = require('electron-notarize');
const { notarize } = require('electron-notarize');
exports.default = async function (context) {
const {electronPlatformName, appOutDir} = context;
if (electronPlatformName !== 'darwin') {
return;
}
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') {
return;
}
const appName = context.packager.appInfo.productFilename;
const appName = context.packager.appInfo.productFilename;
return notarize({
appBundleId: 'org.zulip.zulip-electron',
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASS,
ascProvider: process.env.ASC_PROVIDER // Team short name
});
return await notarize({
appBundleId: 'org.zulip.zulip-electron',
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASS,
});
};

20
scripts/travis-build-test.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# exit script if fails
set -e;
if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then
export {no_proxy,NO_PROXY}="127.0.0.1,localhost"
export DISPLAY=:99.0
sh -e /etc/init.d/xvfb start
sleep 3
echo 'Travis Screen Resolution:'
xdpyinfo | grep dimensions
fi
npm run test
# if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
# npm run test-e2e
# fi

5
scripts/travis-xvfb.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then
/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16
fi

View File

@@ -1,7 +1,7 @@
'use strict';
const path = require('path')
const TEST_APP_PRODUCT_NAME = 'ZulipTest';
const TEST_APP_PRODUCT_NAME = 'ZulipTest'
module.exports = {
TEST_APP_PRODUCT_NAME
};
TEST_APP_PRODUCT_NAME
}

183
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,183 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "2.4.0",
"main": "../../app/main",
"description": "Zulip Desktop App",
"license": "Apache-2.0",
"copyright": "Kandra Labs, Inc.",
"author": {
"name": "Kandra Labs, Inc.",
"email": "support@zulipchat.com"
},
"repository": {
"type": "git",
"url": "https://github.com/zulip/zulip-electron.git"
},
"bugs": {
"url": "https://github.com/zulip/zulip-electron/issues"
},
"engines": {
"node": ">=6.0.0"
},
"scripts": {
"start": "electron app --disable-http-cache --no-electron-connect",
"reinstall": "node ./tools/reinstall-node-modules.js",
"postinstall": "electron-builder install-app-deps",
"test": "xo",
"test-e2e": "gulp test-e2e",
"dev": "gulp dev & nodemon --watch app/main --watch app/renderer --exec 'npm test' -e html,css,js",
"pack": "electron-builder --dir",
"dist": "electron-builder",
"mas": "electron-builder --mac mas",
"travis": "cd ./scripts && ./travis-build-test.sh",
"build-locales": "node tools/locale-helper"
},
"pre-commit": [
"test"
],
"build": {
"appId": "org.zulip.zulip-electron",
"asar": true,
"files": [
"**/*",
"!docs${/*}",
"!node_modules/@paulcbetts/cld/deps/cld${/*}"
],
"copyright": "©2017 Kandra Labs, Inc.",
"mac": {
"category": "public.app-category.productivity",
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"linux": {
"category": "Chat;GNOME;GTK;Network;InstantMessaging",
"packageCategory": "GNOME;GTK;Network;InstantMessaging",
"description": "Zulip Desktop Client for Linux",
"target": [
"deb",
"zip",
"AppImage",
"snap"
],
"maintainer": "Akash Nimare <svnitakash@gmail.com>",
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"deb": {
"synopsis": "Zulip Desktop App",
"afterInstall": "./scripts/debian-add-repo.sh",
"afterRemove": "./scripts/debian-uninstaller.sh"
},
"snap": {
"synopsis": "Zulip Desktop App"
},
"dmg": {
"background": "build/appdmg.png",
"icon": "build/icon.icns",
"iconSize": 100,
"contents": [
{
"x": 380,
"y": 280,
"type": "link",
"path": "/Applications"
},
{
"x": 110,
"y": 280,
"type": "file"
}
],
"window": {
"width": 500,
"height": 500
}
},
"win": {
"target": [
{
"target": "nsis-web",
"arch": [
"x64",
"ia32"
]
}
],
"icon": "build/icon.ico",
"publisherName": "Kandra Labs, Inc."
},
"nsis": {
"allowToChangeInstallationDirectory": true
}
},
"keywords": [
"Zulip",
"Group Chat app",
"electron-app",
"electron",
"Desktop app",
"InstantMessaging"
],
"devDependencies": {
"assert": "1.4.1",
"cp-file": "^5.0.0",
"devtron": "1.4.0",
"electron": "3.0.10",
"electron-builder": "20.38.4",
"electron-connect": "0.6.2",
"electron-debug": "1.4.0",
"google-translate-api": "2.3.0",
"gulp": "^4.0.0",
"gulp-tape": "0.0.9",
"is-ci": "^1.0.10",
"nodemon": "^1.14.11",
"pre-commit": "1.2.2",
"spectron": "3.8.0",
"tap-colorize": "^1.2.0",
"tape": "^4.8.0",
"xo": "0.18.2"
},
"xo": {
"parserOptions": {
"sourceType": "script",
"ecmaFeatures": {
"globalReturn": true
}
},
"esnext": true,
"overrides": [
{
"files": "app*/**/*.js",
"rules": {
"max-lines": [
"warn",
{
"max": 600,
"skipBlankLines": true,
"skipComments": true
}
],
"no-warning-comments": 0,
"object-curly-spacing": 0,
"capitalized-comments": 0,
"no-else-return": 0,
"no-path-concat": 0,
"no-alert": 0,
"guard-for-in": 0,
"prefer-promise-reject-errors": 0,
"import/no-unresolved": 0,
"import/no-extraneous-dependencies": 0,
"no-prototype-builtins": 0
}
}
],
"ignore": [
"tests/*.js",
"tools/locale-helper/*.js"
],
"envs": [
"node",
"browser",
"mocha"
]
}
}

View File

@@ -1,17 +1,13 @@
'use strict';
const test = require('tape');
const setup = require('./setup');
const test = require('tape')
const setup = require('./setup')
test('app runs', async t => {
t.timeoutAfter(10e3);
setup.resetTestDataDir();
const app = setup.createApp();
try {
await setup.waitForLoad(app, t);
await app.client.windowByIndex(1); // Focus on webview
await app.client.waitForExist('//*[@id="connect"]'); // Id of the connect button
await setup.endTest(app, t);
} catch (error) {
await setup.endTest(app, t, error || 'error');
}
});
test('app runs', function (t) {
t.timeoutAfter(10e3)
setup.resetTestDataDir()
const app = setup.createApp()
setup.waitForLoad(app, t)
.then(() => app.client.windowByIndex(1)) // focus on webview
.then(() => app.client.waitForExist('//*[@id="connect"]')) // id of the connect button
.then(() => setup.endTest(app, t),
(err) => setup.endTest(app, t, err || 'error'))
})

View File

@@ -1,96 +1,99 @@
'use strict';
const {Application} = require('spectron');
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const Application = require('spectron').Application
const cpFile = require('cp-file')
const fs = require('fs')
const isCI = require('is-ci')
const mkdirp = require('mkdirp')
const path = require('path')
const rimraf = require('rimraf')
const config = require('./config');
const config = require('./config')
module.exports = {
createApp,
endTest,
waitForLoad,
wait,
resetTestDataDir
};
createApp,
endTest,
waitForLoad,
wait,
resetTestDataDir
}
// Runs Zulip Desktop.
// Returns a promise that resolves to a Spectron Application once the app has loaded.
// Takes a Tape test. Makes some basic assertions to verify that the app loaded correctly.
function createApp() {
generateTestAppPackageJson();
return new Application({
path: path.join(__dirname, '..', 'node_modules', '.bin',
'electron' + (process.platform === 'win32' ? '.cmd' : '')),
args: [path.join(__dirname)], // Ensure this dir has a package.json file with a 'main' entry piont
env: {NODE_ENV: 'test'},
waitTimeout: 10e3
});
function createApp (t) {
generateTestAppPackageJson()
return new Application({
path: path.join(__dirname, '..', 'node_modules', '.bin',
'electron' + (process.platform === 'win32' ? '.cmd' : '')),
args: [path.join(__dirname)], // Ensure this dir has a package.json file with a 'main' entry piont
env: {NODE_ENV: 'test'},
waitTimeout: 10e3
})
}
// Generates package.json for test app
// Reads app package.json and updates the productName to config.TEST_APP_PRODUCT_NAME
// Reads app package.json and updates the productName to config.TEST_APP_PRODUCT_NAME
// We do this so that the app integration doesn't doesn't share the same appDataDir as the dev application
function generateTestAppPackageJson() {
const packageJson = require(path.join(__dirname, '../package.json'));
packageJson.productName = config.TEST_APP_PRODUCT_NAME;
packageJson.main = '../app/main';
function generateTestAppPackageJson () {
let packageJson = require(path.join(__dirname, '../package.json'))
packageJson.productName = config.TEST_APP_PRODUCT_NAME
packageJson.main = '../app/main'
const testPackageJsonPath = path.join(__dirname, 'package.json');
fs.writeFileSync(testPackageJsonPath, JSON.stringify(packageJson, null, ' '), 'utf-8');
const testPackageJsonPath = path.join(__dirname, 'package.json')
fs.writeFileSync(testPackageJsonPath, JSON.stringify(packageJson, null, ' '), 'utf-8')
}
// Starts the app, waits for it to load, returns a promise
async function waitForLoad(app, t, options) {
if (!options) {
options = {};
}
await app.start();
await app.client.waitUntilWindowLoaded();
await app.client.pause(2000);
const title = await app.webContents.getTitle();
t.equal(title, 'Zulip', 'html title');
function waitForLoad (app, t, opts) {
if (!opts) opts = {}
return app.start().then(function () {
return app.client.waitUntilWindowLoaded()
})
.then(function() {
return app.client.pause(2000);
})
.then(function () {
return app.webContents.getTitle()
}).then(function (title) {
t.equal(title, 'Zulip', 'html title')
})
}
// Returns a promise that resolves after 'ms' milliseconds. Default: 1 second
async function wait(ms) {
if (ms === undefined) {
ms = 1000;
} // Default: wait long enough for the UI to update
return new Promise((resolve => {
setTimeout(resolve, ms);
}));
function wait (ms) {
if (ms === undefined) ms = 1000 // Default: wait long enough for the UI to update
return new Promise(function (resolve, reject) {
setTimeout(resolve, ms)
})
}
// Quit the app, end the test, either in success (!err) or failure (err)
async function endTest(app, t, err) {
await app.stop();
t.end(err);
function endTest (app, t, err) {
return app.stop().then(function () {
t.end(err)
})
}
function getAppDataDir() {
let base;
function getAppDataDir () {
let base
if (process.platform === 'darwin') {
base = path.join(process.env.HOME, 'Library', 'Application Support');
} else if (process.platform === 'linux') {
base = process.env.XDG_CONFIG_HOME ?
process.env.XDG_CONFIG_HOME : path.join(process.env.HOME, '.config');
} else if (process.platform === 'win32') {
base = process.env.APPDATA;
} else {
throw new Error('Could not detect app data dir base.');
}
console.log('Detected App Data Dir base:', base);
return path.join(base, config.TEST_APP_PRODUCT_NAME);
if (process.platform === 'darwin') {
base = path.join(process.env.HOME, 'Library', 'Application Support')
} else if (process.platform === 'linux') {
base = process.env.XDG_CONFIG_HOME ?
process.env.XDG_CONFIG_HOME : path.join(process.env.HOME, '.config')
} else if (process.platform === 'win32') {
base = process.env.APPDATA
} else {
console.log('Could not detect app data dir base. Exiting...')
process.exit(1)
}
console.log('Detected App Data Dir base:', base)
return path.join(base, config.TEST_APP_PRODUCT_NAME)
}
// Resets the test directory, containing domain.json, window-state.json, etc
function resetTestDataDir() {
const appDataDir = getAppDataDir();
rimraf.sync(appDataDir);
rimraf.sync(path.join(__dirname, 'package.json'));
function resetTestDataDir () {
appDataDir = getAppDataDir()
rimraf.sync(appDataDir)
rimraf.sync(path.join(__dirname, 'package.json'))
}

View File

@@ -1,22 +1,19 @@
'use strict';
const test = require('tape');
const setup = require('./setup');
const test = require('tape')
const setup = require('./setup')
test('add-organization', function (t) {
t.timeoutAfter(50e3)
setup.resetTestDataDir()
const app = setup.createApp()
setup.waitForLoad(app, t)
.then(() => app.client.windowByIndex(1)) // focus on webview
.then(() => app.client.setValue('.setting-input-value', 'chat.zulip.org'))
.then(() => app.client.click('.server-save-action'))
.then(() => setup.wait(5000))
.then(() => app.client.windowByIndex(0)) // Switch focus back to main win
.then(() => app.client.windowByIndex(1)) // Switch focus back to org webview
.then(() => app.client.waitForExist('//*[@id="id_username"]'))
.then(() => setup.endTest(app, t),
(err) => setup.endTest(app, t, err || 'error'))
})
test('add-organization', async t => {
t.timeoutAfter(50e3);
setup.resetTestDataDir();
const app = setup.createApp();
try {
await setup.waitForLoad(app, t);
await app.client.windowByIndex(1); // Focus on webview
await app.client.setValue('.setting-input-value', 'chat.zulip.org');
await app.client.click('#connect');
await setup.wait(5000);
await app.client.windowByIndex(0); // Switch focus back to main win
await app.client.windowByIndex(1); // Switch focus back to org webview
await app.client.waitForExist('//*[@id="id_username"]');
await setup.endTest(app, t);
} catch (error) {
await setup.endTest(app, t, error || 'error');
}
});

View File

@@ -1,20 +1,17 @@
'use strict';
const test = require('tape');
const setup = require('./setup');
const test = require('tape')
const setup = require('./setup')
// Create new org link should open in the default browser [WIP]
test('new-org-link', async t => {
t.timeoutAfter(50e3);
setup.resetTestDataDir();
const app = setup.createApp();
try {
await setup.waitForLoad(app, t);
await app.client.windowByIndex(1); // Focus on webview
await app.client.click('#open-create-org-link'); // Click on new org link button
await setup.wait(5000);
await setup.endTest(app, t);
} catch (error) {
await setup.endTest(app, t, error || 'error');
}
});
test('new-org-link', function (t) {
t.timeoutAfter(50e3)
setup.resetTestDataDir()
const app = setup.createApp()
setup.waitForLoad(app, t)
.then(() => app.client.windowByIndex(1)) // focus on webview
.then(() => app.client.click('#open-create-org-link')) // Click on new org link button
.then(() => setup.wait(5000))
.then(() => setup.endTest(app, t),
(err) => setup.endTest(app, t, err || 'error'))
})

View File

@@ -1,4 +1,3 @@
'use strict';
const translate = require('@vitalets/google-translate-api');
const path = require('path');
const fs = require('fs');
@@ -6,30 +5,28 @@ const fs = require('fs');
const translationDir = path.resolve(__dirname, '../../app/translations');
function writeJSON(file, data) {
const filePath = path.resolve(translationDir, file);
fs.writeFileSync(filePath, `${JSON.stringify(data, null, '\t')}\n`, 'utf8');
const filePath = path.resolve(translationDir, file);
fs.writeFileSync(filePath, `${JSON.stringify(data, null, '\t')}\n`, 'utf8');
}
const {phrases} = require('./locale-template');
const supportedLocales = require('./supported-locales.json');
const { phrases } = require('./locale-template');
const supportedLocales = require('./supported-locales');
phrases.sort();
for (const [locale, name] of Object.entries(supportedLocales)) {
console.log(`fetching translation for: ${name} - ${locale}..`);
(async () => {
try {
const result = await translate(phrases.join('\n'), {to: locale});
const localeFile = `${locale}.json`;
const translatedText = result.text.split('\n');
const translationJSON = {};
phrases.forEach((phrase, index) => {
translationJSON[phrase] = translatedText[index];
});
for (let locale in supportedLocales) {
console.log(`fetching translation for: ${supportedLocales[locale]} - ${locale}..`);
translate(phrases.join('\n'), { to: locale })
.then(res => {
const localeFile = `${locale}.json`;
const translatedText = res.text.split('\n');
const translationJSON = {};
phrases.forEach((phrase, index) => {
translationJSON[phrase] = translatedText[index];
});
writeJSON(localeFile, translationJSON);
console.log(`create: ${localeFile}`);
} catch (error) {
console.error(error);
}
})();
writeJSON(localeFile, translationJSON);
console.log(`create: ${localeFile}`);
}).catch(err => {
console.error(err);
});
}

View File

@@ -1,4 +1,4 @@
{
module.exports = {
"de": "Deutsch",
"pl": "polski",
"en": "English",
@@ -18,4 +18,4 @@
"es": "español",
"ko": "한국어",
"fr": "français"
}
};

View File

@@ -2,8 +2,9 @@
set -e
set -x
echo "Removing node_modules"
echo "Removing node_modules and app/node_modules"
rm -rf node_modules
rm -rf app/node_modules
echo "node_modules removed reinstalling npm packages"
npm i

View File

@@ -1,7 +1,8 @@
@echo off
echo "Removing node_modules"
echo "Removing node_modules and app/node_modules"
rmdir /s /q node_modules
rmdir /s /q app/node_modules
echo "node_modules removed reinstalling npm packages"
npm i

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env node
'use strict';
const {exec} = require('child_process');
const path = require('path');

Some files were not shown because too many files have changed in this diff Show More