Files
huly-platform/desktop/src/main/start.ts
T

626 lines
20 KiB
TypeScript

//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { config as dotenvConfig } from 'dotenv'
import { globalShortcut, Event, BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences, nativeTheme } from 'electron'
import contextMenu from 'electron-context-menu'
import log from 'electron-log'
import Store from 'electron-store'
import { ProgressInfo, UpdateInfo } from 'electron-updater'
import WinBadge from 'electron-windows-badge'
import * as path from 'path'
import { Config, MenuBarAction, NotificationParams, JumpListSpares, CommandCloseTab } from '../ui/types'
import { getOptions } from './args'
import { addMenus } from './standardMenu'
import { dispatchMenuBarAction } from './customMenu'
import { addPermissionHandlers } from './permissions'
import autoUpdater from './updater'
import { generateId } from '@hcengineering/core'
import { DownloadItem } from '@hcengineering/desktop-downloads'
import { rebuildJumpList, setupWindowsSpecific } from './windowsSpecificSetup'
import { readPackedConfig } from './config'
import { Settings } from './settings'
import { TrayController } from './tray'
import { getFileInPublicBundledFolder } from './path'
import { OsIntegration } from './osIntegration'
import { IpcMessage } from '../ui/ipcMessages'
let isQuiting = false
function quitApplication (): void {
isQuiting = true
app.quit()
}
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
quitApplication()
} else {
runTheApp()
}
function runTheApp (): void {
let winBadge: any
let mainWindow: BrowserWindow | undefined
const isMac = process.platform === 'darwin'
const isWindows = process.platform === 'win32'
const isDev = process.env.NODE_ENV === 'development'
const sessionPartition = !isDev ? 'persist:huly' : 'persist:huly_dev'
const iconKey = getFileInPublicBundledFolder(isWindows ? 'AppIcon.ico' : 'AppIcon.png')
const preloadScriptPath = path.join(app.getAppPath(), 'dist', 'main', 'preload.js')
const defaultWidth = 1440
const defaultHeight = 960
const packedConfig = readPackedConfig()
log.info('packed config', packedConfig)
const envPath = path.join(app.getAppPath(), isDev ? '.env-dev' : '.env')
console.log('do loading env from', envPath)
dotenvConfig({
path: envPath
})
const options = getOptions()
const containerPageFileName = isWindows ? 'index.windows.html' : 'index.html'
const containerPagePath = path.join('dist', 'ui', containerPageFileName)
const CloseTabHotKey = 'CommandOrControl+W'
// Note: using electron-store here as local storage is not available in the main process
// before the window is created
const settings = new Settings(new Store(), isDev, packedConfig)
const oldFront = settings.readServerUrl()
if (options.server !== undefined) {
settings.setServerUrl(options.server)
}
const FRONT_URL = settings.readServerUrl()
const serverChanged = oldFront !== FRONT_URL
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup') === true) {
quitApplication()
}
console.log('Running Huly', process.env.MODEL_VERSION, process.env.VERSION, isMac, isDev, process.env.NODE_ENV)
// Fix screen-sharing thumbnails being missing sometimes
// See https://github.com/electron/electron/issues/44504
const disabledFeatures = [
'ThumbnailCapturerMac:capture_mode/sc_screenshot_manager',
'ScreenCaptureKitPickerScreen',
'ScreenCaptureKitStreamPickerSonoma'
]
app.commandLine.appendSwitch('disable-features', disabledFeatures.join(','))
function setupWindowTitleBar (windowOptions: Electron.BrowserWindowConstructorOptions): void {
if (isWindows) {
// on Windows we use frameless window with custom hand-made title bar
windowOptions.frame = false
} else {
windowOptions.titleBarStyle = isMac ? 'hidden' : 'default'
}
}
function hookOpenWindow (window: BrowserWindow): void {
window.webContents.setWindowOpenHandler(({ url }) => {
console.log('opening window', url)
/*
We need to detect if url is "our" or external. We should
open external urls in system browser
When we open local URLs it would be as file:///workbench/my-spc/tracker/TSK-2
As we load only our index.html there is no security problem to pass such URLs
to open arg as well
*/
if (url.indexOf(FRONT_URL) !== 0 && url.indexOf('file://') !== 0) {
void shell.openExternal(url)
} else {
void (async (): Promise<void> => {
const bounds = mainWindow?.getBounds()
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: bounds?.width ?? defaultWidth,
height: bounds?.height ?? defaultHeight,
x: (bounds?.x ?? 0) + 25,
y: (bounds?.y ?? 0) + 25,
trafficLightPosition: { x: 10, y: 10 },
icon: nativeImage.createFromPath(iconKey),
webPreferences: {
devTools: true,
sandbox: false,
partition: sessionPartition,
nodeIntegration: true,
preload: preloadScriptPath,
additionalArguments: [
`--open=${encodeURI(
new URL(url).pathname
.split('/')
.filter((it) => it.length > 0)
.join('/')
)}`
]
}
}
setupWindowTitleBar(windowOptions)
const childWindow = new BrowserWindow(windowOptions)
await childWindow.loadFile(containerPagePath)
hookOpenWindow(childWindow)
})()
}
return { action: 'deny' }
})
}
function setupCookieHandler (config: Config): void {
const normalizedAccountsUrl = config.ACCOUNTS_URL.endsWith('/') ? config.ACCOUNTS_URL : config.ACCOUNTS_URL + '/'
const urls = [
normalizedAccountsUrl,
normalizedAccountsUrl + '*'
]
session.defaultSession.webRequest.onHeadersReceived({ urls }, handleSetCookie)
session.fromPartition(sessionPartition).webRequest.onHeadersReceived({ urls }, handleSetCookie)
}
function handleSetCookie (details: Electron.OnHeadersReceivedListenerDetails, callback: (headersReceivedResponse: Electron.HeadersReceivedResponse) => void): void {
if (details.responseHeaders !== undefined) {
for (const header in details.responseHeaders) {
if (header.toLowerCase() === 'set-cookie') {
const cookies = details.responseHeaders[header]
details.responseHeaders[header] = cookies.map((cookie) => {
if (!cookie.includes('SameSite=')) {
if (details.url.startsWith('https://') && !cookie.includes('; Secure')) {
cookie += '; Secure'
}
cookie += '; SameSite=None'
}
return cookie
})
}
}
}
// eslint-disable-next-line n/no-callback-literal
callback({ responseHeaders: { ...details.responseHeaders } })
}
function handleAuthRedirects (window: BrowserWindow): void {
window.webContents.on('will-redirect', (event) => {
if (event?.url.startsWith(`${FRONT_URL}/login/auth`)) {
console.log('Auth happened, redirecting to local index')
const urlObj = new URL(decodeURIComponent(event.url))
event.preventDefault()
void (async (): Promise<void> => {
await window.loadFile(containerPagePath)
window.webContents.send(IpcMessage.HandleAuth, urlObj.searchParams.get('token'))
})()
}
})
}
function handleWillDownload (window: BrowserWindow): void {
window.webContents.session.on('will-download', (_event, item) => {
const key = generateId()
const notifyDownloadUpdated = (): void => {
const download: DownloadItem = {
key,
state: item.isPaused() ? 'paused' : item.getState(),
fileName: item.getFilename(),
receivedBytes: item.getReceivedBytes(),
totalBytes: item.getTotalBytes(),
url: item.getURL(),
savePath: item.getSavePath()
}
window.webContents.send(IpcMessage.HandleDownloadItem, download)
}
notifyDownloadUpdated()
item.on('updated', notifyDownloadUpdated)
item.on('done', notifyDownloadUpdated)
})
}
const createWindow = async (): Promise<void> => {
const restoredBounds: any = settings.getWindowBounds()
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: restoredBounds?.width ?? defaultWidth,
height: restoredBounds?.height ?? defaultHeight,
x: restoredBounds?.x ?? undefined,
y: restoredBounds?.y ?? undefined,
trafficLightPosition: { x: 10, y: 10 },
roundedCorners: true,
icon: nativeImage.createFromPath(iconKey),
webPreferences: {
devTools: true,
sandbox: false,
nodeIntegration: true,
partition: sessionPartition,
preload: preloadScriptPath
}
}
setupWindowTitleBar(windowOptions)
mainWindow = new BrowserWindow(windowOptions)
app.dock?.setIcon(nativeImage.createFromPath(iconKey))
if (isDev) {
mainWindow.webContents.openDevTools()
}
await mainWindow.loadFile(containerPagePath)
addPermissionHandlers(mainWindow.webContents.session)
handleAuthRedirects(mainWindow)
handleWillDownload(mainWindow)
// In this example, only windows with the `about:blank` url will be created.
// All other urls will be blocked.
hookOpenWindow(mainWindow)
function minimizeToTrayIsOn (): boolean {
return settings.isMinimizeToTrayEnabled() && osIntegration != null
}
mainWindow.on('close', (event: Event) => {
const bounds = mainWindow?.getBounds()
if (bounds != null) {
settings.setWindowBounds(bounds)
}
if (minimizeToTrayIsOn()) {
if (!isQuiting) {
event.preventDefault()
mainWindow?.hide()
return false
}
}
if (isMac) {
event.preventDefault()
mainWindow?.hide()
}
})
function sendWindowMaximizedMessage (maximized: boolean): void {
mainWindow?.webContents.send(IpcMessage.WindowStateChanged, maximized ? 'maximized' : 'unmaximized')
}
mainWindow.on('focus', () => {
const registered = globalShortcut.register(CloseTabHotKey, () => {
sendCommand(CommandCloseTab)
})
if (!registered) {
console.log(`failed to register global shortcut on ${CloseTabHotKey}`)
}
})
mainWindow.on('blur', () => {
globalShortcut.unregister(CloseTabHotKey)
mainWindow?.webContents.send(IpcMessage.WindowFocusLoss)
})
mainWindow.on('maximize', () => {
sendWindowMaximizedMessage(true)
})
mainWindow.on('minimize', () => {
if (minimizeToTrayIsOn()) {
mainWindow?.hide()
}
})
mainWindow.on('unmaximize', () => {
sendWindowMaximizedMessage(false)
})
mainWindow.on('enter-full-screen', () => {
sendWindowMaximizedMessage(true)
})
mainWindow.on('leave-full-screen', () => {
if (mainWindow != null) {
sendWindowMaximizedMessage(mainWindow.isMaximized())
}
})
if (isWindows) {
winBadge = new WinBadge(mainWindow, {
font: '14px arial'
})
}
}
function sendCommand (cmd: string, ...args: any[]): void {
mainWindow?.webContents.send(cmd, ...args)
}
function activateWindow (): void {
if (mainWindow != null) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
}
let osIntegration: OsIntegration | undefined
void app.whenReady().then(() => {
const trayController = new TrayController(settings, activateWindow, quitApplication)
osIntegration = new OsIntegration(settings, trayController)
})
if (isWindows) {
setupWindowsSpecific(activateWindow, sendCommand)
} else {
addMenus(sendCommand)
}
contextMenu({
showSaveImageAs: false,
showInspectElement: false,
showSelectAll: false
})
ipcMain.on(IpcMessage.SetBadge, (_event: any, badge: number) => {
app.dock?.setBadge(badge > 0 ? `${badge}` : '')
app.badgeCount = badge
if (isWindows && winBadge !== undefined) {
winBadge.update(badge)
}
osIntegration?.getTray().updateTrayBadge(badge)
})
ipcMain.on(IpcMessage.DockBounce, (_event: any) => {
app.dock?.bounce('informational')
})
ipcMain.on(IpcMessage.SendNotification, (_event: any, notificationParams: NotificationParams) => {
if (Notification.isSupported()) {
const notification = new Notification(notificationParams)
notification.on('click', () => {
mainWindow?.show()
mainWindow?.webContents.send(IpcMessage.HandleNotificationNavigation, notificationParams)
})
notification.show()
}
})
ipcMain.on(IpcMessage.SetTitle, (event: any, title: string) => {
const webContents = event.sender
const window = BrowserWindow.fromWebContents(webContents)
window?.setTitle(title)
})
ipcMain.on(IpcMessage.SetCombinedConfig, (_event: any, config: Config) => {
log.info('Config set: ', config)
setupCookieHandler(config)
const updatesUrl = process.env.DESKTOP_UPDATES_URL ?? config.DESKTOP_UPDATES_URL ?? 'https://dist.huly.io'
// NOTE: env format is: default_value;key1:value1;key2:value2...
const updatesChannels = (process.env.DESKTOP_UPDATES_CHANNEL ?? config.DESKTOP_UPDATES_CHANNELS ?? config.DESKTOP_UPDATES_CHANNEL ?? 'huly').split(';').map(c => c.trim().split(':'))
const updateChannelsMap: Record<string, string> = {}
for (const channelInfo of updatesChannels) {
if (channelInfo.length === 1) {
updateChannelsMap.default = channelInfo[0]
} else if (channelInfo.length === 2) {
const [key, value] = channelInfo
updateChannelsMap[key] = value
}
}
const updatesChannelKey = packedConfig?.updatesChannelKey ?? 'default'
const updatesChannel = updateChannelsMap[updatesChannelKey] ?? updateChannelsMap.default ?? 'huly'
log.info('updates channels', updatesChannels)
log.info('updates channel', updatesChannelKey, updatesChannel)
autoUpdater.setFeedURL({
provider: 'generic',
url: updatesUrl,
channel: updatesChannel
})
void autoUpdater.checkForUpdatesAndNotify()
})
ipcMain.handle(IpcMessage.GetMainConfig, (_event: any, _path: any) => {
const cfg = {
CONFIG_URL: process.env.CONFIG_URL ?? '',
FRONT_URL,
INITIAL_URL: process.env.INITIAL_URL ?? '',
MODEL_VERSION: process.env.MODEL_VERSION ?? '',
VERSION: process.env.VERSION ?? ''
}
return cfg
})
ipcMain.handle(IpcMessage.GetHost, (_event: any, _path: any) => {
return new URL(FRONT_URL).host
})
ipcMain.on(IpcMessage.SetFrontCookie, function (event: any, host: string, name: string, value: string) {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
const cv: CookiesSetDetails = {
url: host,
name,
value,
path: '/',
secure: true,
sameSite: 'no_restriction',
httpOnly: true,
domain: process.env.FRONT_DOMAIN
}
void win?.webContents?.session.cookies.set(cv)
})
ipcMain.handle(IpcMessage.WindowMinimize, () => {
mainWindow?.minimize()
})
ipcMain.handle(IpcMessage.WindowMaximize, () => {
if (mainWindow != null) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
}
})
ipcMain.handle(IpcMessage.WindowClose, () => {
mainWindow?.close()
})
ipcMain.handle(IpcMessage.GetIsOsUsingDarkTheme, () => {
return nativeTheme.shouldUseDarkColors
})
ipcMain.handle(IpcMessage.MenuAction, async (_event: any, action: MenuBarAction) => {
dispatchMenuBarAction(mainWindow, action, osIntegration)
})
if (isWindows) {
ipcMain.on(IpcMessage.RebuildUserJumpList, (_event: any, spares: JumpListSpares) => {
rebuildJumpList(spares)
})
}
ipcMain.handle(IpcMessage.GetScreenAccess, () => systemPreferences.getMediaAccessStatus('screen') === 'granted')
ipcMain.handle(IpcMessage.GetScreenSources, () => {
return desktopCapturer.getSources({ types: ['window', 'screen'], fetchWindowIcons: true, thumbnailSize: { width: 225, height: 135 } }).then(async sources => {
return sources.map((source: any) => {
return {
...source,
appIconURL: source.appIcon?.toDataURL(),
thumbnailURL: source.thumbnail.toDataURL()
}
})
})
})
ipcMain.handle('get-minimize-to-tray-enabled', () => {
return settings.isMinimizeToTrayEnabled()
})
ipcMain.handle(IpcMessage.GetAutoLaunchEnabled, () => {
return settings.isAutoLaunchEnabled()
})
async function onReady (): Promise<void> {
await createWindow()
if (serverChanged) {
mainWindow?.webContents.send('logout')
}
}
app.on('ready', () => {
void onReady()
})
app.on('will-quit', () => {
globalShortcut.unregister(CloseTabHotKey)
})
app.on('window-all-closed', () => {
globalShortcut.unregister(CloseTabHotKey)
if (process.platform !== 'darwin') {
app.quit()
}
})
if (isMac) {
app.on('activate', () => {
if (mainWindow != null && (mainWindow.isMinimized() || !mainWindow.isVisible())) {
mainWindow.show()
}
})
}
app.on('before-quit', () => {
isQuiting = true
if (mainWindow !== undefined && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds()
settings.setWindowBounds(bounds)
}
// Note: in case the app is exited by auto-updater all windows will be destroyed at this point
if (mainWindow === undefined || mainWindow.isDestroyed()) {
return
}
if (isMac) {
mainWindow?.removeAllListeners('close')
mainWindow?.close()
}
})
// Note: it is reset when the app is relaunched after update
let isUpdating = false
autoUpdater.on('update-available', (info: UpdateInfo) => {
if (isUpdating) return
void dialog
.showMessageBox({
type: 'info',
buttons: ['Update & Restart', 'Quit'],
defaultId: 0,
message: `A new version ${info.version} is available and it is required to continue. It will be downloaded and installed automatically.`
})
.then(({ response }: any) => {
log.info(`Update dialog exit code: ${response}`) // eslint-disable-line no-console
if (response !== 0) {
quitApplication()
}
isUpdating = true
setDownloadProgress(0)
})
})
autoUpdater.on('download-progress', (progressObj: ProgressInfo) => {
setDownloadProgress(progressObj.percent)
})
function setDownloadProgress (percent: number): void {
if (mainWindow === undefined) {
return
}
mainWindow.setProgressBar(percent / 100)
mainWindow.webContents.send(IpcMessage.HandleUpdateDownloadProgress, percent)
}
autoUpdater.on('update-downloaded', (_info: any) => {
// We have listeners that prevents the app from being exited on mac
app.removeAllListeners('window-all-closed')
mainWindow?.removeAllListeners('close')
autoUpdater.quitAndInstall()
})
}