TidGi-Desktop/public/libs/views.js
2020-08-16 15:59:11 +08:00

792 lines
25 KiB
JavaScript

/* eslint-disable no-param-reassign */
const {
BrowserView,
BrowserWindow,
app,
session,
shell,
dialog,
} = require('electron');
const path = require('path');
const fsExtra = require('fs-extra');
const { ElectronBlocker } = require('@cliqz/adblocker-electron');
const { TIDDLERS_PATH } = require('../constants/paths');
const startNodeJSWiki = require('./wiki/start-nodejs-wiki');
const { watchWiki } = require('./wiki/watch-wiki');
const { getPreferences, getPreference } = require('./preferences');
const {
getWorkspace,
setWorkspace,
} = require('./workspaces');
const {
setWorkspaceMeta,
getWorkspaceMetas,
getWorkspaceMeta,
} = require('./workspace-metas');
const sendToAllWindows = require('./send-to-all-windows');
const getViewBounds = require('./get-view-bounds');
const customizedFetch = require('./customized-fetch');
const views = {};
let shouldMuteAudio;
let shouldPauseNotifications;
const extractDomain = (fullUrl) => {
const matches = fullUrl.match(/^https?:\/\/([^/?#]+)(?:[/?#]|$)/i);
const domain = matches && matches[1];
// https://stackoverflow.com/a/9928725
return domain ? domain.replace(/^(www\.)/, '') : null;
};
// https://stackoverflow.com/a/14645182
const isSubdomain = (url) => {
const regex = new RegExp(/^([a-z]+:\/{2})?([\w-]+\.[\w-]+\.\w+)$/);
return !!url.match(regex); // make sure it returns boolean
};
const equivalentDomain = (domain) => {
if (!domain) return null;
let eDomain = domain;
const prefixes = ['www', 'app', 'login', 'go', 'accounts', 'open'];
// app.portcast.io ~ portcast.io
// login.xero.com ~ xero.com
// go.xero.com ~ xero.com
// accounts.google.com ~ google.com
// open.spotify.com ~ spotify.com
// remove one by one not to break domain
prefixes.forEach((prefix) => {
// check if subdomain, if not return the domain
if (isSubdomain(eDomain)) {
// https://stackoverflow.com/a/9928725
const regex = new RegExp(`^(${prefix}.)`);
eDomain = eDomain.replace(regex, '');
}
});
return eDomain;
};
const isInternalUrl = (url, currentInternalUrls) => {
// google have a lot of redirections after logging in
// so assume any requests made after 'accounts.google.com' are internals
for (let i = 0; i < currentInternalUrls.length; i += 1) {
if (currentInternalUrls[i] && currentInternalUrls[i].startsWith('https://accounts.google.com')) {
return true;
}
}
// external links sent in Google Meet meeting goes through this link first
// https://meet.google.com/linkredirect?authuser=1&dest=https://something.com
if (url.startsWith('https://meet.google.com/linkredirect')) {
return false;
}
const domain = equivalentDomain(extractDomain(url));
const matchedInternalUrl = currentInternalUrls.find((internalUrl) => {
const internalDomain = equivalentDomain(extractDomain(internalUrl));
// Ex: music.yandex.ru => passport.yandex.ru?retpath=....music.yandex.ru
// https://github.com/quanglam2807/webcatalog/issues/546#issuecomment-586639519
if (domain === 'clck.yandex.ru' || domain === 'passport.yandex.ru') {
return url.includes(internalDomain);
}
// domains match
return domain === internalDomain;
});
return Boolean(matchedInternalUrl);
};
const addView = (browserWindow, workspace) => {
if (views[workspace.id] != null) return;
const {
blockAds,
customUserAgent,
proxyBypassRules,
proxyPacScript,
proxyRules,
proxyType,
rememberLastPageVisited,
shareWorkspaceBrowsingData,
spellcheck,
spellcheckLanguages,
unreadCountBadge,
} = getPreferences();
// configure session, proxy & ad blocker
const partitionId = shareWorkspaceBrowsingData ? 'persist:shared' : `persist:${workspace.id}`;
// start wiki on startup
const { name: wikiPath, gitUrl: githubUrl, port, isSubWiki } = workspace;
const userName = getPreference('userName') || '';
const userInfo = getPreference('github-user-info');
if (!userInfo) { // user not logined into Github
dialog.showMessageBox(browserWindow, {
title: 'Github UserInfo No Found',
message: 'Seems you haven\'t login to Github, so we disable syncing for this wiki.',
buttons: ['OK'],
cancelId: 0,
defaultId: 0,
});
}
if (!isSubWiki) { // if is main wiki
startNodeJSWiki(wikiPath, port, userName, workspace.id);
userInfo && watchWiki(wikiPath, githubUrl, userInfo, path.join(wikiPath, TIDDLERS_PATH));
} else { // if is private repo wiki
userInfo && watchWiki(wikiPath, githubUrl, userInfo);
}
// session
const ses = session.fromPartition(partitionId);
// proxy
if (proxyType === 'rules') {
ses.setProxy({
proxyRules,
proxyBypassRules,
});
} else if (proxyType === 'pacScript') {
ses.setProxy({
proxyPacScript,
proxyBypassRules,
});
}
// blocker
if (blockAds) {
ElectronBlocker.fromPrebuiltAdsAndTracking(customizedFetch, {
path: path.join(app.getPath('userData'), 'adblocker.bin'),
read: fsExtra.readFile,
write: fsExtra.writeFile,
}).then((blocker) => {
blocker.enableBlockingInSession(ses);
});
}
// spellchecker
if (spellcheck && process.platform !== 'darwin') {
ses.setSpellCheckerLanguages(spellcheckLanguages);
}
const sharedWebPreferences = {
spellcheck,
nativeWindowOpen: true,
nodeIntegration: false,
contextIsolation: true,
session: ses,
preload: path.join(__dirname, '..', 'preload', 'view.js'),
};
const view = new BrowserView({
webPreferences: sharedWebPreferences,
});
view.webContents.workspaceId = workspace.id;
// background needs to explictly set
// if not, by default, the background of BrowserView is transparent
// which would break the CSS of certain websites
// even with dark mode, all major browsers
// always use #FFF as default page background
// https://github.com/atomery/webcatalog/issues/723
// https://github.com/electron/electron/issues/16212
view.setBackgroundColor('#FFF');
let adjustUserAgentByUrl = () => false;
if (customUserAgent) {
view.webContents.userAgent = customUserAgent;
} else {
// Hide Electron from UA to improve compatibility
// https://github.com/quanglam2807/webcatalog/issues/182
const uaStr = view.webContents.userAgent;
const commonUaStr = uaStr
// Fix WhatsApp requires Google Chrome 49+ bug
.replace(` ${app.name}/${app.getVersion()}`, '')
// Hide Electron from UA to improve compatibility
// https://github.com/quanglam2807/webcatalog/issues/182
.replace(` Electron/${process.versions.electron}`, '');
view.webContents.userAgent = customUserAgent || commonUaStr;
// fix Google prevents signing in because of security concerns
// https://github.com/quanglam2807/webcatalog/issues/455
// https://github.com/meetfranz/franz/issues/1720#issuecomment-566460763
const fakedEdgeUaStr = `${commonUaStr} Edge/18.18875`;
adjustUserAgentByUrl = (contents, url) => {
if (customUserAgent) return false;
const navigatedDomain = extractDomain(url);
const currentUaStr = contents.userAgent;
if (navigatedDomain === 'accounts.google.com') {
if (currentUaStr !== fakedEdgeUaStr) {
contents.userAgent = fakedEdgeUaStr;
return true;
}
} else if (currentUaStr !== commonUaStr) {
contents.userAgent = commonUaStr;
return true;
}
return false;
};
}
view.webContents.on('will-navigate', (e, nextUrl) => {
// open external links in browser
// https://github.com/atomery/webcatalog/issues/849#issuecomment-629587264
// this behavior is likely to break many apps (eg Microsoft Teams)
// apply this rule only to github.com for now
const appUrl = getWorkspace(workspace.id).homeUrl;
const currentUrl = e.sender.getURL();
const appDomain = extractDomain(appUrl);
const currentDomain = extractDomain(currentUrl);
if (
(appDomain.includes('github.com') || currentDomain.includes('github.com'))
&& !isInternalUrl(nextUrl, [appUrl, currentUrl])
) {
e.preventDefault();
shell.openExternal(nextUrl);
return;
}
adjustUserAgentByUrl(e.sender.webContents, nextUrl);
});
view.webContents.on('did-start-loading', () => {
const workspaceObj = getWorkspace(workspace.id);
// this event might be triggered
// even after the workspace obj and BrowserView
// are destroyed. See https://github.com/atomery/webcatalog/issues/836
if (!workspaceObj) return;
if (workspaceObj.active) {
if (getWorkspaceMeta(workspace.id).didFailLoad) {
// show browserView again when reloading after error
// see did-fail-load event
if (browserWindow && !browserWindow.isDestroyed()) { // fix https://github.com/atomery/singlebox/issues/228
const contentSize = browserWindow.getContentSize();
view.setBounds(getViewBounds(contentSize));
}
}
}
setWorkspaceMeta(workspace.id, {
didFailLoad: null,
isLoading: true,
});
});
view.webContents.on('did-stop-loading', () => {
const workspaceObj = getWorkspace(workspace.id);
// this event might be triggered
// even after the workspace obj and BrowserView
// are destroyed. See https://github.com/atomery/webcatalog/issues/836
if (!workspaceObj) return;
// isLoading is now controlled by wiki-worker-manager.js
// setWorkspaceMeta(workspace.id, {
// isLoading: false,
// });
if (workspaceObj.active) {
sendToAllWindows('update-address', view.webContents.getURL(), false);
}
const currentUrl = view.webContents.getURL();
setWorkspace(workspace.id, {
lastUrl: currentUrl,
});
});
if (workspace.active) {
const handleFocus = () => {
// focus on webview
// https://github.com/quanglam2807/webcatalog/issues/398
view.webContents.focus();
view.webContents.removeListener('did-stop-loading', handleFocus);
};
view.webContents.on('did-stop-loading', handleFocus);
}
// https://electronjs.org/docs/api/web-contents#event-did-fail-load
view.webContents.on('did-fail-load', (e, errorCode, errorDesc, validateUrl, isMainFrame) => {
const workspaceObj = getWorkspace(workspace.id);
// this event might be triggered
// even after the workspace obj and BrowserView
// are destroyed. See https://github.com/atomery/webcatalog/issues/836
if (!workspaceObj) return;
if (isMainFrame && errorCode < 0 && errorCode !== -3) {
setWorkspaceMeta(workspace.id, {
didFailLoad: errorDesc,
// isLoading: false, // isLoading is now controlled by wiki-worker-manager.js
});
if (workspaceObj.active) {
if (browserWindow && !browserWindow.isDestroyed()) { // fix https://github.com/atomery/singlebox/issues/228
const contentSize = browserWindow.getContentSize();
view.setBounds(
getViewBounds(contentSize, false, 0, 0),
); // hide browserView to show error message
}
}
}
// edge case to handle failed auth
if (errorCode === -300 && view.webContents.getURL().length === 0) {
view.webContents.loadURL(workspaceObj.homeUrl);
}
});
view.webContents.on('did-navigate', (e, url) => {
const workspaceObj = getWorkspace(workspace.id);
// this event might be triggered
// even after the workspace obj and BrowserView
// are destroyed. See https://github.com/atomery/webcatalog/issues/836
if (!workspaceObj) return;
// fix "Google Chat isn't supported on your current browser"
// https://github.com/atomery/webcatalog/issues/820
if (url && url.indexOf('error/browser-not-supported') > -1 && url.startsWith('https://chat.google.com')) {
const ref = new URL(url).searchParams.get('ref') || '';
view.webContents.loadURL(`https://chat.google.com${ref}`);
}
// fix Google prevents signing in because of security concerns
// https://github.com/quanglam2807/webcatalog/issues/455
// https://github.com/meetfranz/franz/issues/1720#issuecomment-566460763
// will-navigate doesn't trigger for loadURL, goBack, goForward
// so user agent to needed to be double check here
// not the best solution as page will be unexpectedly reloaded
// but it won't happen very often
if (adjustUserAgentByUrl(e.sender.webContents, url)) {
view.webContents.reload();
}
if (workspaceObj.active) {
sendToAllWindows('update-can-go-back', view.webContents.canGoBack());
sendToAllWindows('update-can-go-forward', view.webContents.canGoForward());
sendToAllWindows('update-address', url, false);
}
});
view.webContents.on('did-navigate-in-page', (e, url) => {
const workspaceObj = getWorkspace(workspace.id);
// this event might be triggered
// even after the workspace obj and BrowserView
// are destroyed. See https://github.com/atomery/webcatalog/issues/836
if (!workspaceObj) return;
if (workspaceObj.active) {
sendToAllWindows('update-can-go-back', view.webContents.canGoBack());
sendToAllWindows('update-can-go-forward', view.webContents.canGoForward());
sendToAllWindows('update-address', url, false);
}
});
view.webContents.on('page-title-updated', (e, title) => {
const workspaceObj = getWorkspace(workspace.id);
// this event might be triggered
// even after the workspace obj and BrowserView
// are destroyed. See https://github.com/atomery/webcatalog/issues/836
if (!workspaceObj) return;
if (workspaceObj.active) {
sendToAllWindows('update-title', title);
browserWindow.setTitle(title);
}
});
const handleNewWindow = (e, nextUrl, frameName, disposition, options) => {
const appUrl = getWorkspace(workspace.id).homeUrl;
const appDomain = extractDomain(appUrl);
const currentUrl = e.sender.getURL();
const currentDomain = extractDomain(currentUrl);
const nextDomain = extractDomain(nextUrl);
const openInNewWindow = () => {
// https://gist.github.com/Gvozd/2cec0c8c510a707854e439fb15c561b0
e.preventDefault();
// if 'new-window' is triggered with Cmd+Click
// options is undefined
// https://github.com/atomery/webcatalog/issues/842
const cmdClick = Boolean(!options);
const newOptions = cmdClick ? {
show: true,
width: 800,
height: 600,
webPreferences: sharedWebPreferences,
} : options;
const popupWin = new BrowserWindow(newOptions);
// WebCatalog internal value to determine whether BrowserWindow is popup
popupWin.isPopup = true;
popupWin.setMenuBarVisibility(false);
popupWin.webContents.on('new-window', handleNewWindow);
// fix Google prevents signing in because of security concerns
// https://github.com/atomery/webcatalog/issues/455
// https://github.com/meetfranz/franz/issues/1720#issuecomment-566460763
// will-navigate doesn't trigger for loadURL, goBack, goForward
// so user agent to needed to be double check here
// not the best solution as page will be unexpectedly reloaded
// but it won't happen very often
popupWin.webContents.on('will-navigate', (ee, url) => {
adjustUserAgentByUrl(ee.sender.webContents, url);
});
popupWin.webContents.on('did-navigate', (ee, url) => {
if (adjustUserAgentByUrl(ee.sender.webContents, url)) {
ee.sender.webContents.reload();
}
});
// if 'new-window' is triggered with Cmd+Click
// url is not loaded automatically
// https://github.com/atomery/webcatalog/issues/842
if (cmdClick) {
popupWin.loadURL(nextUrl);
}
e.newGuest = popupWin;
};
// Conditions are listed by order of priority
// if global.forceNewWindow = true
// or regular new-window event
// or if in Google Drive app, open Google Docs files internally https://github.com/atomery/webcatalog/issues/800
// the next external link request will be opened in new window
if (
global.forceNewWindow
|| disposition === 'new-window'
|| disposition === 'default'
|| (appDomain === 'drive.google.com' && nextDomain === 'docs.google.com')
) {
global.forceNewWindow = false;
openInNewWindow();
return;
}
// load in same window
if (
// Google: Add account
nextDomain === 'accounts.google.com'
// Google: Switch account
|| (
nextDomain && nextDomain.indexOf('google.com') > 0
&& isInternalUrl(nextUrl, [appUrl, currentUrl])
&& (
(nextUrl.indexOf('authuser=') > -1) // https://drive.google.com/drive/u/1/priority?authuser=2 (has authuser query)
|| (/\/u\/[0-9]+\/{0,1}$/.test(nextUrl)) // https://mail.google.com/mail/u/1/ (ends with /u/1/)
)
)
// https://github.com/atomery/webcatalog/issues/315
|| ((appDomain.includes('asana.com') || currentDomain.includes('asana.com')) && nextDomain.includes('asana.com'))
) {
e.preventDefault();
adjustUserAgentByUrl(e.sender.webContents, nextUrl);
e.sender.loadURL(nextUrl);
return;
}
// open new window
if (isInternalUrl(nextUrl, [appUrl, currentUrl])) {
openInNewWindow();
return;
}
// special case for Roam Research
// if popup window is not opened and loaded, Roam crashes (shows white page)
// https://github.com/atomery/webcatalog/issues/793
if (
appDomain === 'roamresearch.com'
&& nextDomain != null
&& (disposition === 'foreground-tab' || disposition === 'background-tab')
) {
e.preventDefault();
shell.openExternal(nextUrl);
// mock window
// close as soon as it did-navigate
const newOptions = {
...options,
show: false,
};
const popupWin = new BrowserWindow(newOptions);
// WebCatalog internal value to determine whether BrowserWindow is popup
popupWin.isPopup = true;
popupWin.once('did-navigate', () => {
popupWin.close();
});
e.newGuest = popupWin;
return;
}
// open external url in browser
if (
nextDomain != null
&& (disposition === 'foreground-tab' || disposition === 'background-tab')
) {
e.preventDefault();
shell.openExternal(nextUrl);
return;
}
// App tries to open external link using JS
// nextURL === 'about:blank' but then window will redirect to the external URL
// https://github.com/quanglam2807/webcatalog/issues/467#issuecomment-569857721
if (
nextDomain === null
&& (disposition === 'foreground-tab' || disposition === 'background-tab')
) {
e.preventDefault();
const newOptions = {
...options,
show: false,
};
const popupWin = new BrowserWindow(newOptions);
popupWin.setMenuBarVisibility(false);
popupWin.webContents.on('new-window', handleNewWindow);
popupWin.webContents.once('will-navigate', (_, url) => {
// if the window is used for the current app, then use default behavior
if (isInternalUrl(url, [appUrl, currentUrl])) {
popupWin.show();
} else { // if not, open in browser
e.preventDefault();
shell.openExternal(url);
popupWin.close();
}
});
e.newGuest = popupWin;
}
};
view.webContents.on('new-window', handleNewWindow);
// Handle downloads
// https://electronjs.org/docs/api/download-item
view.webContents.session.on('will-download', (event, item) => {
const {
askForDownloadPath,
downloadPath,
} = getPreferences();
// Set the save path, making Electron not to prompt a save dialog.
if (!askForDownloadPath) {
const finalFilePath = path.join(downloadPath, item.getFilename());
if (!fsExtra.existsSync(finalFilePath)) {
// eslint-disable-next-line no-param-reassign
item.savePath = finalFilePath;
}
} else {
// set preferred path for save dialog
const opts = {
...item.getSaveDialogOptions(),
defaultPath: path.join(downloadPath, item.getFilename()),
};
item.setSaveDialogOptions(opts);
}
});
// Unread count badge
if (unreadCountBadge) {
view.webContents.on('page-title-updated', (e, title) => {
const itemCountRegex = /[([{](\d*?)[}\])]/;
const match = itemCountRegex.exec(title);
const incStr = match ? match[1] : '';
const inc = parseInt(incStr, 10) || 0;
setWorkspaceMeta(workspace.id, {
badgeCount: inc,
});
let count = 0;
const metas = getWorkspaceMetas();
Object.values(metas).forEach((m) => {
if (m && m.badgeCount) {
count += m.badgeCount;
}
});
app.badgeCount = count;
if (process.platform === 'win32') {
if (count > 0) {
browserWindow.setOverlayIcon(
path.resolve(__dirname, '..', 'overlay-icon.png'),
`You have ${count} new messages.`,
);
} else {
browserWindow.setOverlayIcon(null, '');
}
}
});
}
// Find In Page
view.webContents.on('found-in-page', (e, result) => {
sendToAllWindows('update-find-in-page-matches', result.activeMatchOrdinal, result.matches);
});
// Link preview
view.webContents.on('update-target-url', (e, url) => {
try {
view.webContents.send('update-target-url', url);
} catch (err) {
console.log(err); // eslint-disable-line no-console
}
});
// Handle audio & notification preferences
if (shouldMuteAudio !== undefined) {
view.webContents.audioMuted = shouldMuteAudio;
}
view.webContents.once('did-stop-loading', () => {
view.webContents.send('should-pause-notifications-changed', workspace.disableNotifications || shouldPauseNotifications);
});
views[workspace.id] = view;
if (workspace.active) {
browserWindow.setBrowserView(view);
const contentSize = browserWindow.getContentSize();
view.setBounds(getViewBounds(contentSize));
view.setAutoResize({
width: true,
height: true,
});
}
const initialUrl = (rememberLastPageVisited && workspace.lastUrl)
|| workspace.homeUrl;
adjustUserAgentByUrl(view.webContents, initialUrl);
view.webContents.loadURL(initialUrl);
};
const getView = (id) => views[id];
const onEachView = (functionToRun) => Object.keys(views).forEach(key => functionToRun(views[key]));
const setActiveView = (browserWindow, id) => {
// stop find in page when switching workspaces
const currentView = browserWindow.getBrowserView();
if (currentView) {
currentView.webContents.stopFindInPage('clearSelection');
browserWindow.send('close-find-in-page');
}
if (views[id] == null) {
addView(browserWindow, getWorkspace(id));
} else {
const view = views[id];
browserWindow.setBrowserView(view);
const contentSize = browserWindow.getContentSize();
if (getWorkspaceMeta(id).didFailLoad) {
view.setBounds(
getViewBounds(contentSize, false, 0, 0),
); // hide browserView to show error message
} else {
view.setBounds(getViewBounds(contentSize));
}
view.setAutoResize({
width: true,
height: true,
});
// focus on webview
// https://github.com/quanglam2807/webcatalog/issues/398
view.webContents.focus();
sendToAllWindows('update-address', view.webContents.getURL(), false);
sendToAllWindows('update-title', view.webContents.getTitle());
browserWindow.setTitle(view.webContents.getTitle());
}
};
const removeView = (id) => {
const view = views[id];
session.fromPartition(`persist:${id}`).clearStorageData();
if (view != null) {
view.destroy();
}
delete views[id];
};
const setViewsAudioPref = (_shouldMuteAudio) => {
if (_shouldMuteAudio !== undefined) {
shouldMuteAudio = _shouldMuteAudio;
}
Object.keys(views).forEach((id) => {
const view = views[id];
if (view != null) {
const workspace = getWorkspace(id);
view.webContents.audioMuted = workspace.disableAudio || shouldMuteAudio;
}
});
};
const setViewsNotificationsPref = (_shouldPauseNotifications) => {
if (_shouldPauseNotifications !== undefined) {
shouldPauseNotifications = _shouldPauseNotifications;
}
Object.keys(views).forEach((id) => {
const view = views[id];
if (view != null) {
const workspace = getWorkspace(id);
view.webContents.send(
'should-pause-notifications-changed',
Boolean(workspace.disableNotifications || shouldPauseNotifications),
);
}
});
};
const hibernateView = (id) => {
if (views[id] != null) {
views[id].destroy();
views[id] = null;
}
};
const reloadViewsDarkReader = () => {
Object.keys(views).forEach((id) => {
const view = views[id];
if (view != null) {
view.webContents.send('reload-dark-reader');
}
});
};
const reloadViewsWebContentsIfDidFailLoad = () => {
const metas = getWorkspaceMetas();
Object.keys(metas).forEach((id) => {
if (!metas[id].didFailLoad) return;
const view = views[id];
if (view != null) {
view.webContents.reload();
}
});
};
const reloadViewsWebContents = () => {
const metas = getWorkspaceMetas();
Object.keys(metas).forEach((id) => {
const view = views[id];
if (view != null) {
view.webContents.reload();
}
});
};
module.exports = {
addView,
getView,
onEachView,
hibernateView,
reloadViewsDarkReader,
reloadViewsWebContents,
reloadViewsWebContentsIfDidFailLoad,
removeView,
setActiveView,
setViewsAudioPref,
setViewsNotificationsPref,
};