2025-03-16 10:18:32 +00:00
import Logger , { LoggerWrapper , TargetType } from '@joplin/utils/Logger' ;
2020-10-09 18:35:46 +01:00
import { PluginMessage } from './services/plugins/PluginRunner' ;
2024-08-27 20:04:18 +03:00
import AutoUpdaterService , { defaultUpdateInterval , initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService' ;
2024-08-17 14:19:05 +03:00
import type ShimType from '@joplin/lib/shim' ;
const shim : typeof ShimType = require ( '@joplin/lib/shim' ) . default ;
2021-10-16 10:07:41 +01:00
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils' ;
2025-03-16 10:18:32 +00:00
import { FileLocker } from '@joplin/utils/fs' ;
import { IpcMessageHandler , IpcServer , Message , newHttpError , sendMessage , SendMessageOptions , startServer , stopServer } from '@joplin/utils/ipc' ;
import { BrowserWindow , Tray , WebContents , screen , App } from 'electron' ;
2023-10-22 03:52:06 -07:00
import bridge from './bridge' ;
2019-07-30 09:35:42 +02:00
const url = require ( 'url' ) ;
const path = require ( 'path' ) ;
2020-11-07 15:59:37 +00:00
const { dirname } = require ( '@joplin/lib/path-utils' ) ;
2018-03-09 20:59:12 +00:00
const fs = require ( 'fs-extra' ) ;
2023-10-31 08:05:28 -07:00
import { dialog , ipcMain } from 'electron' ;
import { _ } from '@joplin/lib/locale' ;
import restartInSafeModeFromMain from './utils/restartInSafeModeFromMain' ;
2024-07-26 04:22:49 -07:00
import handleCustomProtocols , { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols' ;
2023-12-13 11:45:29 -08:00
import { clearTimeout , setTimeout } from 'timers' ;
2024-11-08 07:32:05 -08:00
import { resolve } from 'path' ;
import { defaultWindowId } from '@joplin/lib/reducer' ;
2025-03-21 12:08:09 +01:00
import { msleep , Second } from '@joplin/utils/time' ;
2017-11-04 12:38:53 +00:00
2020-10-09 18:35:46 +01:00
interface RendererProcessQuitReply {
2020-11-12 19:29:22 +00:00
canClose : boolean ;
2020-10-09 18:35:46 +01:00
}
interface PluginWindows {
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 19:29:22 +00:00
[ key : string ] : any ;
2020-10-09 18:35:46 +01:00
}
2018-03-09 20:59:12 +00:00
2024-11-08 07:32:05 -08:00
type SecondaryWindowId = string ;
interface SecondaryWindowData {
electronId : number ;
}
2020-10-09 18:35:46 +01:00
2025-03-25 10:50:16 -07:00
export interface Options {
env : string ;
profilePath : string | null ;
isDebugMode : boolean ;
isEndToEndTesting : boolean ;
initialCallbackUrl : string ;
}
2024-11-08 07:32:05 -08:00
export default class ElectronAppWrapper {
2020-11-12 19:13:28 +00:00
private logger_ : Logger = null ;
2025-03-16 10:18:32 +00:00
private electronApp_ : App ;
2020-11-12 19:13:28 +00:00
private env_ : string ;
private isDebugMode_ : boolean ;
private profilePath_ : string ;
2025-03-25 10:50:16 -07:00
private isEndToEndTesting_ : boolean ;
2024-11-08 07:32:05 -08:00
2023-02-22 18:15:21 +00:00
private win_ : BrowserWindow = null ;
2024-11-08 07:32:05 -08:00
private mainWindowHidden_ = true ;
private pluginWindows_ : PluginWindows = { } ;
private secondaryWindows_ : Map < SecondaryWindowId , SecondaryWindowData > = new Map ( ) ;
2023-06-30 09:07:03 +01:00
private willQuitApp_ = false ;
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 19:13:28 +00:00
private tray_ : any = null ;
private buildDir_ : string = null ;
private rendererProcessQuitReply_ : RendererProcessQuitReply = null ;
2024-11-08 07:32:05 -08:00
2021-10-16 10:07:41 +01:00
private initialCallbackUrl_ : string = null ;
2024-08-17 14:19:05 +03:00
private updaterService_ : AutoUpdaterService = null ;
2024-07-26 04:22:49 -07:00
private customProtocolHandler_ : CustomProtocolHandler = null ;
2024-08-27 20:04:18 +03:00
private updatePollInterval_ : ReturnType < typeof setTimeout > | null = null ;
2020-11-12 19:13:28 +00:00
2025-03-16 10:18:32 +00:00
private profileLocker_ : FileLocker | null = null ;
private ipcServer_ : IpcServer | null = null ;
private ipcStartPort_ = 2658 ;
private ipcLogger_ : Logger ;
2025-03-25 10:50:16 -07:00
public constructor ( electronApp : App , { env , profilePath , isDebugMode , initialCallbackUrl , isEndToEndTesting } : Options ) {
2017-11-04 12:38:53 +00:00
this . electronApp_ = electronApp ;
2017-11-13 18:47:35 +00:00
this . env_ = env ;
2020-09-01 22:25:23 +01:00
this . isDebugMode_ = isDebugMode ;
2018-04-23 21:50:29 +02:00
this . profilePath_ = profilePath ;
2021-10-16 10:07:41 +01:00
this . initialCallbackUrl_ = initialCallbackUrl ;
2025-03-25 10:50:16 -07:00
this . isEndToEndTesting_ = isEndToEndTesting ;
2025-03-16 10:18:32 +00:00
this . profileLocker_ = new FileLocker ( ` ${ this . profilePath_ } /lock ` ) ;
// Note: in certain contexts `this.logger_` doesn't seem to be available, especially for IPC
// calls, either because it hasn't been set or other issue. So we set one here specifically
// for this.
this . ipcLogger_ = new Logger ( ) ;
this . ipcLogger_ . addTarget ( TargetType . File , {
path : ` ${ profilePath } /log-cross-app-ipc.txt ` ,
} ) ;
2017-11-04 13:23:15 +00:00
}
2017-11-04 12:38:53 +00:00
2023-03-06 14:22:01 +00:00
public electronApp() {
2017-11-04 13:23:15 +00:00
return this . electronApp_ ;
}
2023-03-06 14:22:01 +00:00
public setLogger ( v : Logger ) {
2017-11-04 13:23:15 +00:00
this . logger_ = v ;
}
2023-03-06 14:22:01 +00:00
public logger() {
2017-11-04 13:23:15 +00:00
return this . logger_ ;
2017-11-04 12:38:53 +00:00
}
2024-11-08 07:32:05 -08:00
public mainWindow() {
2017-11-05 00:17:48 +00:00
return this . win_ ;
2017-11-04 19:46:37 +00:00
}
2024-11-08 07:32:05 -08:00
public activeWindow() {
return BrowserWindow . getFocusedWindow ( ) ? ? this . win_ ;
}
public windowById ( joplinId : string ) {
if ( joplinId === defaultWindowId ) {
return this . mainWindow ( ) ;
}
const windowData = this . secondaryWindows_ . get ( joplinId ) ;
if ( windowData !== undefined ) {
return BrowserWindow . fromId ( windowData . electronId ) ;
}
return null ;
}
2024-12-09 07:56:40 -08:00
public allAppWindows() {
const allWindowIds = [ . . . this . secondaryWindows_ . keys ( ) , defaultWindowId ] ;
return allWindowIds . map ( id = > this . windowById ( id ) ) ;
}
2023-03-06 14:22:01 +00:00
public env() {
2019-12-18 11:49:44 +00:00
return this . env_ ;
}
2023-03-06 14:22:01 +00:00
public initialCallbackUrl() {
2021-10-16 10:07:41 +01:00
return this . initialCallbackUrl_ ;
}
2023-10-31 08:05:28 -07:00
// Call when the app fails in a significant way.
//
// Assumes that the renderer process may be in an invalid state and so cannot
// be accessed.
public async handleAppFailure ( errorMessage : string , canIgnore : boolean , isTesting? : boolean ) {
2024-01-25 11:33:04 +00:00
await bridge ( ) . captureException ( new Error ( errorMessage ) ) ;
2023-10-31 08:05:28 -07:00
const buttons = [ ] ;
buttons . push ( _ ( 'Quit' ) ) ;
const exitIndex = 0 ;
if ( canIgnore ) {
buttons . push ( _ ( 'Ignore' ) ) ;
}
const restartIndex = buttons . length ;
buttons . push ( _ ( 'Restart in safe mode' ) ) ;
const { response } = await dialog . showMessageBox ( {
message : _ ( 'An error occurred: %s' , errorMessage ) ,
buttons ,
} ) ;
if ( response === restartIndex ) {
await restartInSafeModeFromMain ( ) ;
// A hung renderer seems to prevent the process from exiting completely.
// In this case, crashing the renderer allows the window to close.
//
// Also only run this if not testing (crashing the renderer breaks automated
// tests).
if ( this . win_ && ! this . win_ . webContents . isCrashed ( ) && ! isTesting ) {
this . win_ . webContents . forcefullyCrashRenderer ( ) ;
}
} else if ( response === exitIndex ) {
process . exit ( 1 ) ;
}
}
2023-03-06 14:22:01 +00:00
public createWindow() {
2019-12-17 12:09:57 +00:00
// Set to true to view errors if the application does not start
2020-09-01 22:25:23 +01:00
const debugEarlyBugs = this . env_ === 'dev' || this . isDebugMode_ ;
2019-12-17 12:09:57 +00:00
2018-03-09 20:59:12 +00:00
const windowStateKeeper = require ( 'electron-window-state' ) ;
2017-11-14 18:02:58 +00:00
2020-02-28 00:38:08 +06:00
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 19:13:28 +00:00
const stateOptions : any = {
2020-03-13 23:52:40 +00:00
defaultWidth : Math.round ( 0.8 * screen . getPrimaryDisplay ( ) . workArea . width ) ,
defaultHeight : Math.round ( 0.8 * screen . getPrimaryDisplay ( ) . workArea . height ) ,
2019-09-19 22:51:18 +01:00
file : ` window-state- ${ this . env_ } .json ` ,
2019-07-30 09:35:42 +02:00
} ;
2018-04-23 21:50:29 +02:00
if ( this . profilePath_ ) stateOptions . path = this . profilePath_ ;
// Load the previous state with fallback to defaults
const windowState = windowStateKeeper ( stateOptions ) ;
2017-11-14 18:02:58 +00:00
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 19:13:28 +00:00
const windowOptions : any = {
2018-02-27 20:04:38 +00:00
x : windowState.x ,
y : windowState.y ,
width : windowState.width ,
height : windowState.height ,
2020-02-28 00:38:08 +06:00
minWidth : 100 ,
minHeight : 100 ,
2019-07-30 09:35:42 +02:00
backgroundColor : '#fff' , // required to enable sub pixel rendering, can't be in css
2019-02-24 10:17:37 +00:00
webPreferences : {
nodeIntegration : true ,
2021-10-01 19:35:27 +01:00
contextIsolation : false ,
2020-11-05 16:58:23 +00:00
spellcheck : true ,
2020-11-19 21:01:19 +00:00
enableRemoteModule : true ,
2019-02-24 10:17:37 +00:00
} ,
2019-12-17 12:09:57 +00:00
webviewTag : true ,
2019-11-05 17:03:24 +00:00
// We start with a hidden window, which is then made visible depending on the showTrayIcon setting
// https://github.com/laurent22/joplin/issues/2031
2023-07-10 03:59:09 -07:00
//
// On Linux/GNOME, however, the window doesn't show correctly if show is false initially:
// https://github.com/laurent22/joplin/issues/8256
show : debugEarlyBugs || shim . isGNOME ( ) ,
2017-12-15 07:31:57 +00:00
} ;
// Linux icon workaround for bug https://github.com/electron-userland/electron-builder/issues/2098
// Fix: https://github.com/electron-userland/electron-builder/issues/2269
2019-01-09 10:05:28 -07:00
if ( shim . isLinux ( ) ) windowOptions . icon = path . join ( __dirname , '..' , 'build/icons/128x128.png' ) ;
2017-12-15 07:31:57 +00:00
2019-07-30 09:35:42 +02:00
this . win_ = new BrowserWindow ( windowOptions ) ;
2017-11-04 12:38:53 +00:00
2021-10-01 19:35:27 +01:00
require ( '@electron/remote/main' ) . enable ( this . win_ . webContents ) ;
2020-02-28 00:38:08 +06:00
if ( ! screen . getDisplayMatching ( this . win_ . getBounds ( ) ) ) {
const { width : windowWidth , height : windowHeight } = this . win_ . getBounds ( ) ;
const { width : primaryDisplayWidth , height : primaryDisplayHeight } = screen . getPrimaryDisplay ( ) . workArea ;
2020-03-13 23:52:40 +00:00
this . win_ . setPosition ( primaryDisplayWidth / 2 - windowWidth , primaryDisplayHeight / 2 - windowHeight ) ;
2020-02-28 00:38:08 +06:00
}
2023-12-13 11:45:29 -08:00
let unresponsiveTimeout : ReturnType < typeof setTimeout > | null = null ;
this . win_ . webContents . on ( 'unresponsive' , ( ) = > {
// Don't show the "unresponsive" dialog immediately -- the "unresponsive" event
// can be fired when showing a dialog or modal (e.g. the update dialog).
//
// This gives us an opportunity to cancel it.
if ( unresponsiveTimeout === null ) {
const delayMs = 1000 ;
unresponsiveTimeout = setTimeout ( ( ) = > {
unresponsiveTimeout = null ;
void this . handleAppFailure ( _ ( 'Window unresponsive.' ) , true ) ;
} , delayMs ) ;
}
} ) ;
this . win_ . webContents . on ( 'responsive' , ( ) = > {
if ( unresponsiveTimeout !== null ) {
clearTimeout ( unresponsiveTimeout ) ;
unresponsiveTimeout = null ;
}
2023-10-31 08:05:28 -07:00
} ) ;
this . win_ . webContents . on ( 'render-process-gone' , async _event = > {
await this . handleAppFailure ( 'Renderer process gone.' , false ) ;
} ) ;
this . win_ . webContents . on ( 'did-fail-load' , async event = > {
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2023-10-31 08:05:28 -07:00
if ( ( event as any ) . isMainFrame ) {
await this . handleAppFailure ( 'Renderer process failed to load' , false ) ;
}
} ) ;
2024-11-08 07:32:05 -08:00
this . mainWindowHidden_ = ! windowOptions . show ;
this . win_ . on ( 'hide' , ( ) = > {
this . mainWindowHidden_ = true ;
} ) ;
this . win_ . on ( 'show' , ( ) = > {
this . mainWindowHidden_ = false ;
} ) ;
2023-02-22 18:15:21 +00:00
void this . win_ . loadURL ( url . format ( {
2018-03-09 20:59:12 +00:00
pathname : path.join ( __dirname , 'index.html' ) ,
protocol : 'file:' ,
2019-07-30 09:35:42 +02:00
slashes : true ,
} ) ) ;
2017-11-04 12:38:53 +00:00
2019-12-17 12:09:57 +00:00
// Note that on Windows, calling openDevTools() too early results in a white window with no error message.
// Waiting for one of the ready events might work but they might not be triggered if there's an error, so
// the easiest is to use a timeout. Keep in mind that if you get a white window on Windows it might be due
// to this line though.
2020-09-11 23:33:34 +01:00
if ( debugEarlyBugs ) {
2025-02-24 16:55:52 +00:00
// Since a recent release of Electron (v34?), calling openDevTools() here does nothing
// if a plugin devtool window is already opened. Maybe because they do a check on
// `isDevToolsOpened` which indeed returns `true` (but shouldn't since it's for a
// different window). However, if you open the dev tools twice from the Help menu it
// works. So instead we do that here and call openDevTool() three times.
let openDevToolCount = 0 ;
const openDevToolInterval = setInterval ( ( ) = > {
2020-09-11 23:33:34 +01:00
try {
this . win_ . webContents . openDevTools ( ) ;
2025-02-24 16:55:52 +00:00
openDevToolCount ++ ;
if ( openDevToolCount >= 3 ) {
clearInterval ( openDevToolInterval ) ;
}
2020-09-11 23:33:34 +01:00
} catch ( error ) {
2025-02-24 16:55:52 +00:00
// This will throw an exception "Object has been destroyed" if the app is closed
// in less that the timeout interval. It can be ignored.
2020-09-11 23:33:34 +01:00
console . warn ( 'Error opening dev tools' , error ) ;
}
2025-02-24 16:55:52 +00:00
} , 1000 ) ;
2020-09-11 23:33:34 +01:00
}
2017-11-04 12:38:53 +00:00
2024-10-15 08:38:33 -07:00
const addWindowEventHandlers = ( webContents : WebContents ) = > {
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
webContents . on ( 'will-frame-navigate' , event = > {
// If the link changes the URL of the browser window,
if ( event . isMainFrame ) {
event . preventDefault ( ) ;
void bridge ( ) . openExternal ( event . url ) ;
}
} ) ;
// Override calls to window.open and links with target="_blank": Open most in a browser instead
// of Electron:
webContents . setWindowOpenHandler ( ( event ) = > {
if ( event . url === 'about:blank' ) {
// Script-controlled pages: Used for opening notes in new windows
return {
action : 'allow' ,
2024-11-08 07:32:05 -08:00
overrideBrowserWindowOptions : {
webPreferences : {
preload : resolve ( __dirname , './utils/window/secondaryWindowPreload.js' ) ,
} ,
} ,
2024-10-15 08:38:33 -07:00
} ;
} else if ( event . url . match ( /^https?:\/\// ) ) {
void bridge ( ) . openExternal ( event . url ) ;
}
return { action : 'deny' } ;
} ) ;
webContents . on ( 'did-create-window' , ( event ) = > {
addWindowEventHandlers ( event . webContents ) ;
} ) ;
} ;
addWindowEventHandlers ( this . win_ . webContents ) ;
2023-10-22 03:52:06 -07:00
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 19:13:28 +00:00
this . win_ . on ( 'close' , ( event : any ) = > {
2018-01-31 20:10:32 +00:00
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)
// otherwise the window is simply hidden, and will be re-open once the app is "activated" (which happens when the
// user clicks on the icon in the task bar).
// On Windows and Linux, the app is closed when the window is closed *except* if the tray icon is used. In which
2019-10-29 05:02:42 -04:00
// case the app must be explicitly closed with Ctrl+Q or by right-clicking on the tray icon and selecting "Exit".
2018-01-31 20:10:32 +00:00
2020-04-09 18:57:20 +01:00
let isGoingToExit = false ;
2018-03-09 20:59:12 +00:00
if ( process . platform === 'darwin' ) {
2018-01-31 20:10:32 +00:00
if ( this . willQuitApp_ ) {
2020-04-09 18:57:20 +01:00
isGoingToExit = true ;
2018-01-31 20:10:32 +00:00
} else {
event . preventDefault ( ) ;
2018-02-27 21:54:40 +00:00
this . hide ( ) ;
2018-01-31 20:10:32 +00:00
}
2017-11-17 18:05:25 +00:00
} else {
2024-11-08 07:32:05 -08:00
const hasBackgroundWindows = this . secondaryWindows_ . size > 0 ;
if ( ( hasBackgroundWindows || this . trayShown ( ) ) && ! this . willQuitApp_ ) {
2018-01-31 20:10:32 +00:00
event . preventDefault ( ) ;
this . win_ . hide ( ) ;
} else {
2020-04-09 18:57:20 +01:00
isGoingToExit = true ;
2018-01-31 20:10:32 +00:00
}
2017-11-17 18:05:25 +00:00
}
2020-04-09 18:57:20 +01:00
if ( isGoingToExit ) {
if ( ! this . rendererProcessQuitReply_ ) {
// If we haven't notified the renderer process yet, do it now
// so that it can tell us if we can really close the app or not.
// Search for "appClose" event for closing logic on renderer side.
event . preventDefault ( ) ;
2023-08-04 07:00:39 -03:00
if ( this . win_ ) this . win_ . webContents . send ( 'appClose' ) ;
2020-04-09 18:57:20 +01:00
} else {
// If the renderer process has responded, check if we can close or not
if ( this . rendererProcessQuitReply_ . canClose ) {
// Really quit the app
this . rendererProcessQuitReply_ = null ;
this . win_ = null ;
} else {
// Wait for renderer to finish task
event . preventDefault ( ) ;
this . rendererProcessQuitReply_ = null ;
}
}
}
} ) ;
2024-11-08 07:32:05 -08:00
ipcMain . on ( 'secondary-window-added' , ( event , windowId : string ) = > {
const window = BrowserWindow . fromWebContents ( event . sender ) ;
const electronWindowId = window ? . id ;
this . secondaryWindows_ . set ( windowId , { electronId : electronWindowId } ) ;
2024-12-09 07:56:40 -08:00
// Match the main window's zoom:
window . webContents . setZoomFactor ( this . mainWindow ( ) . webContents . getZoomFactor ( ) ) ;
2024-11-08 07:32:05 -08:00
window . once ( 'close' , ( ) = > {
this . secondaryWindows_ . delete ( windowId ) ;
const allSecondaryWindowsClosed = this . secondaryWindows_ . size === 0 ;
const mainWindowVisuallyClosed = this . mainWindowHidden_ ;
if ( allSecondaryWindowsClosed && mainWindowVisuallyClosed && ! this . trayShown ( ) ) {
// Gracefully quit the app if the user has closed all windows
this . win_ . close ( ) ;
}
} ) ;
} ) ;
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 19:13:28 +00:00
ipcMain . on ( 'asynchronous-message' , ( _event : any , message : string , args : any ) = > {
2020-04-09 18:57:20 +01:00
if ( message === 'appCloseReply' ) {
// We got the response from the renderer process:
// save the response and try quit again.
this . rendererProcessQuitReply_ = args ;
2022-04-20 17:34:58 +01:00
this . quit ( ) ;
2020-04-09 18:57:20 +01:00
}
2019-07-30 09:35:42 +02:00
} ) ;
2017-11-14 18:02:58 +00:00
2020-10-09 18:35:46 +01:00
// This handler receives IPC messages from a plugin or from the main window,
// and forwards it to the main window or the plugin window.
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-12 19:13:28 +00:00
ipcMain . on ( 'pluginMessage' , ( _event : any , message : PluginMessage ) = > {
2021-04-11 11:58:45 +02:00
try {
if ( message . target === 'mainWindow' ) {
this . win_ . webContents . send ( 'pluginMessage' , message ) ;
2020-10-09 18:35:46 +01:00
}
2021-04-11 11:58:45 +02:00
if ( message . target === 'plugin' ) {
const win = this . pluginWindows_ [ message . pluginId ] ;
if ( ! win ) {
2025-03-16 10:18:32 +00:00
this . ipcLogger_ . error ( ` Trying to send IPC message to non-existing plugin window: ${ message . pluginId } ` ) ;
2021-04-11 11:58:45 +02:00
return ;
}
win . webContents . send ( 'pluginMessage' , message ) ;
}
} catch ( error ) {
// An error might happen when the app is closing and a plugin
// sends a message. In which case, the above code would try to
// access a destroyed webview.
// https://github.com/laurent22/joplin/issues/4570
console . error ( 'Could not process plugin message:' , message ) ;
console . error ( error ) ;
2020-10-09 18:35:46 +01:00
}
} ) ;
2024-08-17 14:19:05 +03:00
ipcMain . on ( 'apply-update-now' , ( ) = > {
this . updaterService_ . updateApp ( ) ;
} ) ;
2024-09-21 15:02:22 +03:00
ipcMain . on ( 'check-for-updates' , ( ) = > {
void this . updaterService_ . checkForUpdates ( true ) ;
} ) ;
2017-11-14 18:02:58 +00:00
// Let us register listeners on the window, so we can update the state
// automatically (the listeners will be removed when the window is closed)
// and restore the maximized or full screen state
windowState . manage ( this . win_ ) ;
2020-02-06 09:15:40 +11:00
2020-02-05 19:26:57 +08:00
// HACK: Ensure the window is hidden, as `windowState.manage` may make the window
// visible with isMaximized set to true in window-state-${this.env_}.json.
// https://github.com/laurent22/joplin/issues/2365
if ( ! windowOptions . show ) {
this . win_ . hide ( ) ;
}
2017-11-04 12:38:53 +00:00
}
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2023-03-06 14:22:01 +00:00
public registerPluginWindow ( pluginId : string , window : any ) {
2020-10-09 18:35:46 +01:00
this . pluginWindows_ [ pluginId ] = window ;
}
2023-03-06 14:22:01 +00:00
public async waitForElectronAppReady() {
2017-11-04 13:23:15 +00:00
if ( this . electronApp ( ) . isReady ( ) ) return Promise . resolve ( ) ;
2017-11-04 12:38:53 +00:00
2021-10-16 10:07:41 +01:00
return new Promise < void > ( ( resolve ) = > {
2017-11-04 12:38:53 +00:00
const iid = setInterval ( ( ) = > {
2017-11-04 13:23:15 +00:00
if ( this . electronApp ( ) . isReady ( ) ) {
2017-11-04 12:38:53 +00:00
clearInterval ( iid ) ;
2021-09-06 16:57:07 +01:00
resolve ( null ) ;
2017-11-04 12:38:53 +00:00
}
} , 10 ) ;
} ) ;
}
2025-03-16 10:18:32 +00:00
private onExit() {
2024-08-27 20:04:18 +03:00
this . stopPeriodicUpdateCheck ( ) ;
2025-03-16 10:18:32 +00:00
this . profileLocker_ . unlockSync ( ) ;
// Probably doesn't matter if the server is not closed cleanly? Thus the lack of `await`
// eslint-disable-next-line promise/prefer-await-to-then -- Needed here because onExit() is not async
void stopServer ( this . ipcServer_ ) . catch ( _error = > {
// Ignore it since we're stopping, and to prevent unnecessary messages.
} ) ;
}
public quit() {
this . onExit ( ) ;
2017-11-04 13:23:15 +00:00
this . electronApp_ . quit ( ) ;
}
2023-03-06 14:22:01 +00:00
public exit ( errorCode = 0 ) {
2025-03-16 10:18:32 +00:00
this . onExit ( ) ;
2018-03-07 19:11:55 +00:00
this . electronApp_ . exit ( errorCode ) ;
}
2023-03-06 14:22:01 +00:00
public trayShown() {
2018-01-31 20:10:32 +00:00
return ! ! this . tray_ ;
}
2018-02-27 21:54:40 +00:00
// This method is used in macOS only to hide the whole app (and not just the main window)
2019-10-29 05:02:42 -04:00
// including the menu bar. This follows the macOS way of hiding an app.
2023-03-06 14:22:01 +00:00
public hide() {
2018-02-27 21:54:40 +00:00
this . electronApp_ . hide ( ) ;
}
2023-03-06 14:22:01 +00:00
public buildDir() {
2018-02-06 13:11:59 +00:00
if ( this . buildDir_ ) return this . buildDir_ ;
2019-09-19 22:51:18 +01:00
let dir = ` ${ __dirname } /build ` ;
2018-02-06 13:11:59 +00:00
if ( ! fs . pathExistsSync ( dir ) ) {
2019-09-19 22:51:18 +01:00
dir = ` ${ dirname ( __dirname ) } /build ` ;
2018-03-09 20:59:12 +00:00
if ( ! fs . pathExistsSync ( dir ) ) throw new Error ( 'Cannot find build dir' ) ;
2018-02-06 13:11:59 +00:00
}
this . buildDir_ = dir ;
return dir ;
2018-02-05 22:19:21 +00:00
}
2023-03-06 14:22:01 +00:00
private trayIconFilename_() {
2018-03-09 20:59:12 +00:00
let output = '' ;
2018-02-20 00:41:52 +00:00
2018-03-09 20:59:12 +00:00
if ( process . platform === 'darwin' ) {
output = 'macos-16x16Template.png' ; // Electron Template Image format
2018-02-20 00:41:52 +00:00
} else {
2018-03-09 20:59:12 +00:00
output = '16x16.png' ;
2018-02-20 00:41:52 +00:00
}
2019-07-30 09:35:42 +02:00
if ( this . env_ === 'dev' ) output = '16x16-dev.png' ;
2018-02-20 00:41:52 +00:00
return output ;
}
2018-01-31 20:10:32 +00:00
// Note: this must be called only after the "ready" event of the app has been dispatched
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2023-03-06 14:22:01 +00:00
public createTray ( contextMenu : any ) {
2018-02-06 13:11:59 +00:00
try {
2019-09-19 22:51:18 +01:00
this . tray_ = new Tray ( ` ${ this . buildDir ( ) } /icons/ ${ this . trayIconFilename_ ( ) } ` ) ;
2020-01-26 12:32:00 -05:00
this . tray_ . setToolTip ( this . electronApp_ . name ) ;
2019-07-30 09:35:42 +02:00
this . tray_ . setContextMenu ( contextMenu ) ;
2018-02-06 13:11:59 +00:00
2018-03-09 20:59:12 +00:00
this . tray_ . on ( 'click' , ( ) = > {
2024-11-08 07:32:05 -08:00
if ( ! this . mainWindow ( ) ) {
2024-02-08 13:51:32 -03:00
console . warn ( 'The window object was not available during the click event from tray icon' ) ;
return ;
}
2024-11-08 07:32:05 -08:00
this . mainWindow ( ) . show ( ) ;
2018-02-06 13:11:59 +00:00
} ) ;
} catch ( error ) {
2019-07-30 09:35:42 +02:00
console . error ( 'Cannot create tray' , error ) ;
2018-02-06 13:11:59 +00:00
}
2018-01-31 20:10:32 +00:00
}
2023-03-06 14:22:01 +00:00
public destroyTray() {
2018-01-31 20:10:32 +00:00
if ( ! this . tray_ ) return ;
this . tray_ . destroy ( ) ;
this . tray_ = null ;
}
2025-03-16 10:18:32 +00:00
public async sendCrossAppIpcMessage ( message : Message , port : number | null = null , options : SendMessageOptions = null ) {
this . ipcLogger_ . info ( 'Sending message:' , message ) ;
2018-03-01 20:14:06 +00:00
2025-03-16 10:18:32 +00:00
if ( port === null ) port = this . ipcStartPort_ ;
2018-02-23 17:51:23 +00:00
2025-03-16 10:18:32 +00:00
return await sendMessage ( port , { . . . message , sourcePort : this.ipcServer_.port } , {
logger : this.ipcLogger_ ,
. . . options ,
} ) ;
}
public async ensureSingleInstance() {
2025-03-25 10:50:16 -07:00
// When end-to-end testing, multiple instances of Joplin are intentionally created at the same time,
// or very close to one another. The single instance handling logic can interfere with this, so disable it.
if ( this . isEndToEndTesting_ ) return false ;
2025-03-16 10:18:32 +00:00
interface OnSecondInstanceMessageData {
profilePath : string ;
argv : string [ ] ;
2019-02-16 13:10:37 +00:00
}
2018-02-23 17:51:23 +00:00
2025-03-16 10:18:32 +00:00
const activateWindow = ( argv : string [ ] ) = > {
2024-11-08 07:32:05 -08:00
const win = this . mainWindow ( ) ;
2019-02-16 13:10:37 +00:00
if ( ! win ) return ;
if ( win . isMinimized ( ) ) win . restore ( ) ;
win . show ( ) ;
2024-04-01 15:34:22 +01:00
// eslint-disable-next-line no-restricted-properties
2019-02-16 13:10:37 +00:00
win . focus ( ) ;
2021-10-16 10:07:41 +01:00
if ( process . platform !== 'darwin' ) {
const url = argv . find ( ( arg ) = > isCallbackUrl ( arg ) ) ;
if ( url ) {
void this . openCallbackUrl ( url ) ;
}
}
2025-03-16 10:18:32 +00:00
} ;
const messageHandlers : Record < string , IpcMessageHandler > = {
'onSecondInstance' : async ( message ) = > {
const data = message . data as OnSecondInstanceMessageData ;
if ( data . profilePath === this . profilePath_ ) activateWindow ( data . argv ) ;
} ,
'restartAltInstance' : async ( message ) = > {
if ( bridge ( ) . altInstanceId ( ) ) return false ;
// We do this in a timeout after a short interval because we need this call to
// return the response immediately, so that the caller can call `quit()`
setTimeout ( async ( ) = > {
const maxWait = 10000 ;
const interval = 300 ;
const loopCount = Math . ceil ( maxWait / interval ) ;
let callingAppGone = false ;
for ( let i = 0 ; i < loopCount ; i ++ ) {
const response = await this . sendCrossAppIpcMessage ( {
action : 'ping' ,
data : null ,
} , message . sourcePort , {
sendToSpecificPortOnly : true ,
} ) ;
if ( ! response . length ) {
callingAppGone = true ;
break ;
}
await msleep ( interval ) ;
}
if ( callingAppGone ) {
2025-03-21 23:26:54 +01:00
// Wait a bit more because even if the app is not responding, the process
// might still be there for a short while.
await msleep ( 1000 ) ;
2025-03-16 10:18:32 +00:00
this . ipcLogger_ . warn ( 'restartAltInstance: App is gone - restarting it' ) ;
void bridge ( ) . launchNewAppInstance ( this . env ( ) ) ;
} else {
this . ipcLogger_ . warn ( 'restartAltInstance: Could not restart calling app because it was still open' ) ;
}
} , 100 ) ;
return true ;
} ,
'ping' : async ( _message ) = > {
return true ;
} ,
} ;
this . ipcServer_ = await startServer ( this . ipcStartPort_ , async ( message ) = > {
if ( messageHandlers [ message . action ] ) {
this . ipcLogger_ . info ( 'Got message:' , message ) ;
return messageHandlers [ message . action ] ( message ) ;
}
throw newHttpError ( 404 ) ;
} , {
logger : this.ipcLogger_ ,
2018-02-23 17:51:23 +00:00
} ) ;
2019-02-16 13:10:37 +00:00
2025-03-16 10:18:32 +00:00
// First check that no other app is running from that profile folder
const gotAppLock = await this . profileLocker_ . lock ( ) ;
if ( gotAppLock ) return false ;
const message : Message = {
action : 'onSecondInstance' ,
data : {
senderPort : this.ipcServer_.port ,
profilePath : this.profilePath_ ,
argv : process.argv ,
} ,
} ;
await this . sendCrossAppIpcMessage ( message ) ;
this . quit ( ) ;
2025-03-21 12:08:09 +01:00
if ( this . env ( ) === 'dev' ) console . warn ( ` Closing the application because another instance is already running, or the previous instance was force-quit within the last ${ Math . round ( this . profileLocker_ . options . interval / Second ) } seconds. ` ) ;
2025-03-16 10:18:32 +00:00
return true ;
2018-02-23 17:51:23 +00:00
}
2024-07-26 04:22:49 -07:00
public initializeCustomProtocolHandler ( logger : LoggerWrapper ) {
this . customProtocolHandler_ ? ? = handleCustomProtocols ( logger ) ;
}
2024-08-27 20:04:18 +03:00
// Electron's autoUpdater has to be init from the main process
2024-09-21 15:02:22 +03:00
public initializeAutoUpdaterService ( logger : LoggerWrapper , devMode : boolean , includePreReleases : boolean ) {
2024-08-17 14:19:05 +03:00
if ( shim . isWindows ( ) || shim . isMac ( ) ) {
2024-08-27 20:04:18 +03:00
if ( ! this . updaterService_ ) {
this . updaterService_ = new AutoUpdaterService ( this . win_ , logger , devMode , includePreReleases ) ;
this . startPeriodicUpdateCheck ( ) ;
}
2024-08-17 14:19:05 +03:00
}
}
2024-08-27 20:04:18 +03:00
private startPeriodicUpdateCheck = ( updateInterval : number = defaultUpdateInterval ) : void = > {
this . stopPeriodicUpdateCheck ( ) ;
this . updatePollInterval_ = setInterval ( ( ) = > {
2024-09-21 15:02:22 +03:00
void this . updaterService_ . checkForUpdates ( false ) ;
2024-08-27 20:04:18 +03:00
} , updateInterval ) ;
setTimeout ( this . updaterService_ . checkForUpdates , initialUpdateStartup ) ;
} ;
private stopPeriodicUpdateCheck = ( ) : void = > {
if ( this . updatePollInterval_ ) {
clearInterval ( this . updatePollInterval_ ) ;
this . updatePollInterval_ = null ;
2024-09-21 15:02:22 +03:00
this . updaterService_ = null ;
2024-08-17 14:19:05 +03:00
}
2024-08-27 20:04:18 +03:00
} ;
2024-08-17 14:19:05 +03:00
2024-07-26 04:22:49 -07:00
public getCustomProtocolHandler() {
return this . customProtocolHandler_ ;
}
2023-03-06 14:22:01 +00:00
public async start() {
2017-11-04 12:38:53 +00:00
// Since we are doing other async things before creating the window, we might miss
// the "ready" event. So we use the function below to make sure that the app is ready.
await this . waitForElectronAppReady ( ) ;
2025-03-16 10:18:32 +00:00
const alreadyRunning = await this . ensureSingleInstance ( ) ;
2019-07-30 09:35:42 +02:00
if ( alreadyRunning ) return ;
2018-02-23 17:51:23 +00:00
2017-11-04 12:38:53 +00:00
this . createWindow ( ) ;
2018-03-09 20:59:12 +00:00
this . electronApp_ . on ( 'before-quit' , ( ) = > {
2017-11-17 18:05:25 +00:00
this . willQuitApp_ = true ;
2019-07-30 09:35:42 +02:00
} ) ;
2017-11-17 18:05:25 +00:00
2018-03-09 20:59:12 +00:00
this . electronApp_ . on ( 'window-all-closed' , ( ) = > {
2022-04-20 17:34:58 +01:00
this . quit ( ) ;
2019-07-30 09:35:42 +02:00
} ) ;
2017-11-04 12:38:53 +00:00
2018-03-09 20:59:12 +00:00
this . electronApp_ . on ( 'activate' , ( ) = > {
2017-11-17 18:05:25 +00:00
this . win_ . show ( ) ;
2019-07-30 09:35:42 +02:00
} ) ;
2021-10-16 10:07:41 +01:00
2024-04-05 12:16:49 +01:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2021-10-16 10:07:41 +01:00
this . electronApp_ . on ( 'open-url' , ( event : any , url : string ) = > {
event . preventDefault ( ) ;
void this . openCallbackUrl ( url ) ;
} ) ;
}
2023-03-06 14:22:01 +00:00
public async openCallbackUrl ( url : string ) {
2021-10-16 10:07:41 +01:00
this . win_ . webContents . send ( 'asynchronous-message' , 'openCallbackUrl' , {
url : url ,
} ) ;
2017-11-04 12:38:53 +00:00
}
2018-03-09 20:59:12 +00:00
2017-11-04 12:38:53 +00:00
}