UNPKG

tiny-electron-essentials

Version:

A lightweight and modular utility library for Electron apps, offering simplified window management, tray support, IPC channels, and custom frameless window styling.

1,556 lines 50.5 kB
'use strict';

var events = require('events');
var electron = require('electron');
var Events = require('../global/Events.cjs');
var Utils = require('../global/Utils.cjs');
var LoadingHtml = require('./LoadingHtml.cjs');

/**
 * Represents the result of installing a loading page, providing methods
 * to control its insertion and removal from the DOM.
 *
 * @typedef {Object} InstallLoadingPageResult
 * @property {() => void} append - Appends the loading screen elements to the document.
 * @property {() => void} remove - Removes the loading screen elements from the document.
 */

/**
 * Represents the rectangular bounds of a window on the screen.
 *
 * @typedef {Object} Bounds
 * @property {number} x - The horizontal position of the window (distance from the left of the screen).
 * @property {number} y - The vertical position of the window (distance from the top of the screen).
 * @property {number} width - The width of the window in pixels.
 * @property {number} height - The height of the window in pixels.
 */

/**
 * A tuple representing a 2D position in pixels.
 *
 * @typedef {number[]} Position
 * @property {number} 0 - The horizontal position (x).
 * @property {number} 1 - The vertical position (y).
 */

/**
 * A tuple representing the size of a window in pixels.
 *
 * @typedef {number[]} Size
 * @property {number} 0 - The width.
 * @property {number} 1 - The height.
 */

/**
 * Represents the current window state and its capabilities.
 *
 * @typedef {Object} WindowDataResult
 * @property {Bounds} bounds
 * @property {boolean} isMaximizable - Indicates whether the window can be maximized.
 * @property {boolean} isClosable - Indicates whether the window can be closed.
 * @property {boolean} isFullScreenable - Indicates whether the window can enter fullscreen mode.
 * @property {boolean} isFocusable - Indicates whether the window can be focused.
 * @property {boolean} isFullScreen - True if the window is currently in fullscreen mode.
 * @property {boolean} isFocused - True if the window is currently focused.
 * @property {boolean} isMaximized - True if the window is currently maximized.
 */

/**
 * Represents the client-side API exposed by the Electron preload script,
 * enabling secure and controlled communication between the page and preload process.
 *
 * @typedef {Object} TinyElectronClientApi
 *
 * Registers a listener for the specified event.
 * @property {(event: string | symbol, listener: ListenerCallback) => void} on
 *
 * Removes a listener from the specified event.
 * @property {(event: string | symbol, listener: ListenerCallback) => void} off
 *
 * Registers a one-time listener for the specified event.
 * @property {(event: string | symbol, listener: ListenerCallback) => void} once
 *
 * Opens the Developer Tools (DevTools) for the window.
 * @property {(ops: Electron.OpenDevToolsOptions) => Promise<void>} openDevTools
 *
 * Sets the window title for the BrowserWindow.
 * @property {(title: string) => Promise<void>} setTitle
 *
 * Retrieves the current internal visibility status flag.
 * May differ from actual visibility (`isVisible`) for internal tracking purposes.
 * @property {() => boolean} getShowStatus
 *
 * Returns an object containing runtime data about the current session or app instance.
 * This data is typically provided by the main process.
 * @property {() => Record<string, any>} getData
 *
 * Indicates whether the application window is currently visible on the screen.
 * @property {() => boolean} isVisible
 *
 * Indicates whether the application window is currently focused.
 * @property {() => boolean} isFocused
 *
 * Indicates whether the application window is currently maximized.
 * @property {() => boolean} isMaximized
 *
 * Checks whether the window is in fullscreen mode.
 * @property {() => boolean} isFullScreen
 *
 * Returns whether the window is currently maximizable.
 * @property {() => boolean} isMaximizable
 *
 * Returns whether the window is currently closable.
 * @property {() => boolean} isClosable
 *
 * Indicates whether the window can enter fullscreen mode.
 * @property {() => boolean} isFullScreenable
 *
 * Returns whether the window is currently focusable.
 * @property {() => boolean} isFocusable
 *
 * Sets whether the window can be maximized.
 * @property {(value: boolean) => Promise<boolean>} setMaximizable
 *
 * Sets whether the window can be closed.
 * @property {(value: boolean) => Promise<boolean>} setClosable
 *
 * Sets whether the window can be focused.
 * @property {(value: boolean) => Promise<boolean>} setFocusable
 *
 * Sets whether the window can enter fullscreen mode.
 * @property {(value: boolean) => Promise<boolean>} setFullScreenable
 *
 * Requests the current window data from the main process.
 * @property {() => Promise<WindowDataResult>} getWindowData
 *
 * Returns a key-value object representing cached state/data stored by the main process.
 * @property {() => Record<string, any>} getCache
 *
 * Sends a request to the main process to update and resend the latest cache state.
 * @property {() => Promise<Record<string, *>>} requestCache
 *
 * Sends a request to forcibly focus the application window, even if it’s not currently visible or active.
 * @property {() => Promise<void>} forceFocus
 *
 * Retrieves the current change count for a specific key.
 * @property {(where: string) => number} getChangeCount
 *
 * Retrieves all current change counters.
 * @property {() => Record<string, number>} getAllChangeCount
 *
 * Retrieves the current window bounds including position and size.
 * @property {() => Bounds} getBounds
 *
 * Retrieves the current size of the window.
 * @property {() => Size} getSize
 *
 * Retrieves the current position of the window.
 * @property {() => Position} getPosition
 *
 * Brings the application window to the front and gives it focus.
 * @property {() => Promise<void>} focus
 *
 * Removes focus from the application window, if currently focused.
 * @property {() => Promise<void>} blur
 *
 * Makes the application window visible.
 * @property {() => Promise<void>} show
 *
 * Hides the application window from view (but does not quit the app).
 * @property {() => Promise<void>} hide
 *
 * Closes the application window (but does not quit the app).
 * @property {() => Promise<void>} close
 *
 * Destroy the application window (but does not quit the app).
 * @property {() => Promise<void>} destroy
 *
 * Maximizes the application window to fill the screen.
 * @property {() => Promise<void>} maximize
 *
 * Restores the application window from maximized state to its previous size.
 * @property {() => Promise<void>} unmaximize
 *
 * Minimizes the application window to the taskbar/dock.
 * @property {() => Promise<void>} minimize
 *
 * Requests the application to quit immediately.
 * @property {() => void} quit
 *
 * Retrieves the amount of system idle time in seconds.
 * @property {() => Promise<number>} systemIdleTime
 *
 * Determines the current system idle state.
 * @property {(idleThreshold: number) => Promise<"active" | "idle" | "locked" | "unknown">} systemIdleState
 *
 * Returns the absolute path to the current executable of the running application.
 * @property {() => string} getExecPath
 *
 * Changes the tray icon to the specified image.
 * `img` should be a valid image file.
 * @property {(img: string, id: string) => Promise<void>} changeTrayIcon
 *
 * Changes the application window or dock icon (depending on platform).
 * `img` should be a valid image file.
 * @property {(img: string) => Promise<void>} changeAppIcon
 *
 * Sets the internal visibility flag.
 * @property {(isVisible?: boolean) => Promise<boolean>} setIsVisible
 *
 * Updates the application's network proxy settings.
 * Requires an Electron `ProxyConfig` object with appropriate options.
 * @property {(config: Electron.ProxyConfig) => Promise<void>} setProxy
 */

/**
 * Manages the state and communication of a single Electron window instance natively.
 *
 * This class handles window state tracking, including focus, visibility,
 * fullscreen, and maximized states. It also manages cached data, window data,
 * and the application show status. TinyElectronClient is designed to be used
 * in Electron applications that require precise tracking of window status
 * and reliable data synchronization between processes.
 *
 * Typical usage includes listening for window events, managing UI state,
 * and handling first-time connections or pings from the window.
 *
 * Example usage:
 * const client = new TinyElectronClient();
 * client.isVisible(); // Returns true if the window is visible
 *
 * @class
 */
class TinyElectronClient {
  /** @typedef {import('./LoadingHtml.mjs').GetLoadingHtml} GetLoadingHtml */

  #AppEvents = Events.AppEvents;
  #exposeInMainWorld = '';

  /**
   * Checks if a given value exists in the AppEvents values.
   *
   * @param {string} value - The value to check for.
   * @returns {boolean} True if the value exists, false otherwise.
   */
  isValidAppEvent(value) {
    return Object.keys(this.#AppEvents).includes(value);
  }

  /**
   * Gets the key (event name) associated with a given AppEvents value.
   *
   * @param {string} value - The value to look up.
   * @returns {string} The matching AppEvents key.
   * @throws {Error} If the value is not found.
   */
  getAppEventKey(value) {
    if (!this.isValidAppEvent(value)) throw new Error(`AppEvent value "${value}" not found.`);
    // @ts-ignore
    if (typeof this.#AppEvents[value] !== 'string')
      throw new Error(`AppEvent value "${value}" is invalid.`);
    // @ts-ignore
    return this.#AppEvents[value];
  }

  /**
   * Important instance used to make event emitter.
   * @type {EventEmitter}
   */
  #events = new events.EventEmitter();

  /**
   * Important instance used to make system event emitter.
   * @type {EventEmitter}
   */
  #sysEvents = new events.EventEmitter();
  #sysEventsUsed = false;

  /**
   * Provides access to a secure internal EventEmitter for subclass use only.
   *
   * This method exposes a dedicated EventEmitter instance intended specifically for subclasses
   * that extend the main class. It prevents subclasses from accidentally or intentionally using
   * the primary class's public event system (`emit`), which could lead to unpredictable behavior
   * or interference in the base class's event flow.
   *
   * For security and consistency, this method is designed to be accessed only once.
   * Multiple accesses are blocked to avoid leaks or misuse of the internal event bus.
   *
   * @returns {EventEmitter} A special internal EventEmitter instance for subclass use.
   * @throws {Error} If the method is called more than once.
   */
  getSysEvents() {
    if (this.#sysEventsUsed)
      throw new Error(
        'Access denied: getSysEvents() can only be called once. ' +
          'This restriction ensures subclass event isolation and prevents accidental interference ' +
          'with the main class event emitter.',
      );
    this.#sysEventsUsed = true;
    return this.#sysEvents;
  }

  /**
   * Emits an event with optional arguments to all system emit.
   * @param {string | symbol} event - The name of the event to emit.
   * @param {...any} args - Arguments passed to event listeners.
   */
  #emit(event, ...args) {
    this.#events.emit(event, ...args);
    if (this.#sysEventsUsed) this.#sysEvents.emit(event, ...args);
  }

  /**
   * @typedef {(...args: any[]) => void} ListenerCallback
   * A generic callback function used for event listeners.
   */

  /**
   * Sets the maximum number of listeners for the internal event emitter.
   *
   * @param {number} max - The maximum number of listeners allowed.
   */
  setMaxListeners(max) {
    this.#events.setMaxListeners(max);
  }

  /**
   * Emits an event with optional arguments.
   * @param {string | symbol} event - The name of the event to emit.
   * @param {...any} args - Arguments passed to event listeners.
   * @returns {boolean} `true` if the event had listeners, `false` otherwise.
   */
  emit(event, ...args) {
    return this.#events.emit(event, ...args);
  }

  /**
   * Registers a listener for the specified event.
   * @param {string | symbol} event - The name of the event to listen for.
   * @param {ListenerCallback} listener - The callback function to invoke.
   * @returns {this} The current class instance (for chaining).
   */
  on(event, listener) {
    this.#events.on(event, listener);
    return this;
  }

  /**
   * Registers a one-time listener for the specified event.
   * @param {string | symbol} event - The name of the event to listen for once.
   * @param {ListenerCallback} listener - The callback function to invoke.
   * @returns {this} The current class instance (for chaining).
   */
  once(event, listener) {
    this.#events.once(event, listener);
    return this;
  }

  /**
   * Removes a listener from the specified event.
   * @param {string | symbol} event - The name of the event.
   * @param {ListenerCallback} listener - The listener to remove.
   * @returns {this} The current class instance (for chaining).
   */
  off(event, listener) {
    this.#events.off(event, listener);
    return this;
  }

  /**
   * Alias for `on`.
   * @param {string | symbol} event - The name of the event.
   * @param {ListenerCallback} listener - The callback to register.
   * @returns {this} The current class instance (for chaining).
   */
  addListener(event, listener) {
    this.#events.addListener(event, listener);
    return this;
  }

  /**
   * Alias for `off`.
   * @param {string | symbol} event - The name of the event.
   * @param {ListenerCallback} listener - The listener to remove.
   * @returns {this} The current class instance (for chaining).
   */
  removeListener(event, listener) {
    this.#events.removeListener(event, listener);
    return this;
  }

  /**
   * Removes all listeners for a specific event, or all events if no event is specified.
   * @param {string | symbol} [event] - The name of the event. If omitted, all listeners from all events will be removed.
   * @returns {this} The current class instance (for chaining).
   */
  removeAllListeners(event) {
    this.#events.removeAllListeners(event);
    return this;
  }

  /**
   * Returns the number of times the given `listener` is registered for the specified `event`.
   * If no `listener` is passed, returns how many listeners are registered for the `event`.
   * @param {string | symbol} eventName - The name of the event.
   * @param {Function} [listener] - Optional listener function to count.
   * @returns {number} Number of matching listeners.
   */
  listenerCount(eventName, listener) {
    return this.#events.listenerCount(eventName, listener);
  }

  /**
   * Adds a listener function to the **beginning** of the listeners array for the specified event.
   * The listener is called every time the event is emitted.
   * @param {string | symbol} eventName - The event name.
   * @param {ListenerCallback} listener - The callback function.
   * @returns {this} The current class instance (for chaining).
   */
  prependListener(eventName, listener) {
    this.#events.prependListener(eventName, listener);
    return this;
  }

  /**
   * Adds a **one-time** listener function to the **beginning** of the listeners array.
   * The next time the event is triggered, this listener is removed and then invoked.
   * @param {string | symbol} eventName - The event name.
   * @param {ListenerCallback} listener - The callback function.
   * @returns {this} The current class instance (for chaining).
   */
  prependOnceListener(eventName, listener) {
    this.#events.prependOnceListener(eventName, listener);
    return this;
  }

  /**
   * Returns an array of event names for which listeners are currently registered.
   * @returns {(string | symbol)[]} Array of event names.
   */
  eventNames() {
    return this.#events.eventNames();
  }

  /**
   * Gets the current maximum number of listeners allowed for any single event.
   * @returns {number} The max listener count.
   */
  getMaxListeners() {
    return this.#events.getMaxListeners();
  }

  /**
   * Returns a copy of the listeners array for the specified event.
   * @param {string | symbol} eventName - The event name.
   * @returns {Function[]} An array of listener functions.
   */
  listeners(eventName) {
    return this.#events.listeners(eventName);
  }

  /**
   * Returns a copy of the internal listeners array for the specified event,
   * including wrapper functions like those used by `.once()`.
   * @param {string | symbol} eventName - The event name.
   * @returns {Function[]} An array of raw listener functions.
   */
  rawListeners(eventName) {
    return this.#events.rawListeners(eventName);
  }

  #fullscreen = false;
  #visible = false;
  #focused = false;
  #maximized = false;

  #isFullScreenable = true;
  #isMaximizable = true;
  #isClosable = true;
  #isFocusable = true;

  /** @type {Bounds} */
  #bounds = { x: 0, y: 0, width: 0, height: 0 };

  #appShow = true;
  #pinged = false;

  /** @type {Record<string, number>} */
  #eventPings = {};

  /** @type {Record<string, number>} */
  #changeCount = {};

  /** @type {Record<string, any>} */
  data = {};

  /** @type {Record<string, any>} */
  cache = {};

  /**
   * Increases the change counter for a specific key.
   * Initializes the counter if it does not exist.
   *
   * @param {string} where - The key representing the context or type of change.
   */
  #addCount(where) {
    if (typeof this.#changeCount[where] !== 'number') this.#changeCount[where] = 0;
    this.#changeCount[where]++;
  }

  /**
   * Retrieves the current change count for a specific key.
   *
   * @param {string} where - The key representing the context or type of change.
   * @returns {number} The current change count. Returns 0 if not initialized.
   */
  getChangeCount(where) {
    if (typeof this.#changeCount[where] !== 'number') return 0;
    return this.#changeCount[where];
  }

  /**
   * Retrieves all current change counters.
   *
   * @returns {Record<string, number>} An object mapping each key to its change count.
   */
  getAllChangeCount() {
    return { ...this.#changeCount };
  }

  /**
   * Sets the initial data received on the first ping from the window.
   *
   * @param {Record<string,*>} data - The initial data object.
   * @param {boolean} useIt
   */
  #firstPing(data, useIt) {
    if (useIt) {
      this.#pinged = true;
      this.data = data;
    }
    this.#addCount('ping');
  }

  /**
   * Updates the cached data of the window instance.
   *
   * @param {Record<string,*>} value - The cache data object to store.
   * @param {boolean} useIt
   */
  #setCache(value, useIt) {
    if (useIt) this.cache = value;
    this.#addCount('cache');
  }

  /**
   * Updates the internal maximizable state.
   * Increases the change counter for 'isMaximizable'.
   *
   * @param {boolean} value - The new maximizable state.
   * @param {boolean} useIt
   */
  #setIsMaximizable(value, useIt) {
    if (useIt) this.#isMaximizable = value;
    this.#addCount('isMaximizable');
  }

  /**
   * Updates the internal closable state.
   * Increases the change counter for 'isClosable'.
   *
   * @param {boolean} value - The new closable state.
   * @param {boolean} useIt
   */
  #setIsClosable(value, useIt) {
    if (useIt) this.#isClosable = value;
    this.#addCount('isClosable');
  }

  /**
   * Updates the internal fullScreenable state.
   * Increases the change counter for 'isFullScreenable'.
   *
   * @param {boolean} value - The new closable state.
   * @param {boolean} useIt
   */
  #setIsFullScreenable(value, useIt) {
    if (useIt) this.#isFullScreenable = value;
    this.#addCount('isFullScreenable');
  }

  /**
   * Updates the internal focusable state.
   * Increases the change counter for 'isFocusable'.
   *
   * @param {boolean} value - The new focusable state.
   * @param {boolean} useIt
   */
  #setIsFocusable(value, useIt) {
    if (useIt) this.#isFocusable = value;
    this.#addCount('isFocusable');
  }

  /**
   * Sets the visibility status of the window.
   *
   * @param {boolean} value - True if the window is visible, false otherwise.
   * @param {boolean} useIt
   */
  #setIsVisible(value, useIt) {
    if (useIt) this.#visible = value;
    this.#addCount('isVisible');
  }

  /**
   * Sets the fullscreen status of the window.
   *
   * @param {boolean} value - True if the window is in fullscreen mode, false otherwise.
   * @param {boolean} useIt
   */
  #setIsFullScreen(value, useIt) {
    if (useIt) this.#fullscreen = value;
    this.#addCount('isFullScreen');
  }

  /**
   * Sets the focus status of the window.
   *
   * @param {boolean} value - True if the window is focused, false otherwise.
   * @param {boolean} useIt
   */
  #setIsFocused(value, useIt) {
    if (useIt) this.#focused = value;
    this.#addCount('isFocused');
  }

  /**
   * Sets whether the application is shown or hidden.
   *
   * @param {boolean} value - True if the application is shown, false if hidden.
   * @param {boolean} useIt
   */
  #setShowStatus(value, useIt) {
    if (useIt) this.#appShow = value;
    this.#addCount('showStatus');
  }

  /**
   * Updates the current position of the window.
   *
   * @param {Position} value An array with two numbers: [x, y] representing the new position.
   * @param {boolean} useIt
   */
  #setPosition(value, useIt) {
    if (useIt) {
      this.#bounds.x = value[0];
      this.#bounds.y = value[1];
    }
    this.#addCount('position');
  }

  /**
   * Updates the current size of the window.
   *
   * @param {Size} value An array with two numbers: [width, height] representing the new size.
   * @param {boolean} useIt
   */
  #setSize(value, useIt) {
    if (useIt) {
      this.#bounds.width = value[0];
      this.#bounds.height = value[1];
    }
    this.#addCount('size');
  }

  /**
   * Sets the maximized status of the window.
   *
   * @param {boolean} value - True if the window is maximized, false otherwise.
   * @param {boolean} useIt
   */
  #setIsMaximized(value, useIt) {
    if (useIt) this.#maximized = value;
    this.#addCount('isMaximized');
  }

  /**
   * Retrieves the cached data of the window.
   *
   * @returns {Record<string,*>} The cached data object.
   */
  getCache() {
    return this.cache;
  }

  /**
   * Retrieves the latest data received from the window.
   *
   * @returns {Record<string,*>} The current data object.
   */
  getData() {
    return this.data;
  }

  /**
   * Retrieves the current window bounds including position and size.
   *
   * @returns {Bounds} An object containing the current x, y, width, and height of the window.
   */
  getBounds() {
    return { ...this.#bounds };
  }

  /**
   * Retrieves the current position of the window.
   *
   * @returns {Position} An array with two numbers: [x, y] representing the top-left corner of the window.
   */
  getPosition() {
    return [this.#bounds.x, this.#bounds.y];
  }

  /**
   * Retrieves the current size of the window.
   *
   * @returns {Size} An array with two numbers: [width, height] representing the width and height of the window.
   */
  getSize() {
    return [this.#bounds.width, this.#bounds.height];
  }

  /**
   * Gets whether the application is currently shown.
   *
   * @returns {boolean} True if the application is shown, false if hidden.
   */
  getShowStatus() {
    return this.#appShow;
  }

  /**
   * Checks whether the window is in fullscreen mode.
   *
   * @returns {boolean} True if the window is in fullscreen, false otherwise.
   */
  isFullScreen() {
    return this.#fullscreen;
  }

  /**
   * Returns whether the window is currently maximizable.
   *
   * @returns {boolean} True if the window can be maximized; otherwise, false.
   */
  isMaximizable() {
    return this.#isMaximizable;
  }

  /**
   * Returns whether the window is currently closable.
   *
   * @returns {boolean} True if the window can be closed; otherwise, false.
   */
  isClosable() {
    return this.#isClosable;
  }

  /**
   * Returns whether the window is currently fullscreenable.
   *
   * @returns {boolean} True if the window can enter fullscreen; otherwise, false.
   */
  isFullScreenable() {
    return this.#isFullScreenable;
  }

  /**
   * Returns whether the window is currently focusable.
   *
   * @returns {boolean} True if the window can be focused; otherwise, false.
   */
  isFocusable() {
    return this.#isFocusable;
  }

  /**
   * Checks whether the window has received the first ping.
   *
   * @returns {boolean} True if the window has been pinged, false otherwise.
   */
  isPinged() {
    return this.#pinged;
  }

  /**
   * Checks whether the window is currently focused.
   *
   * @returns {boolean} True if the window is focused, false otherwise.
   */
  isFocused() {
    return this.#focused;
  }

  /**
   * Checks whether the window is currently visible.
   *
   * @returns {boolean} True if the window is visible, false otherwise.
   */
  isVisible() {
    return this.#visible;
  }

  /**
   * Checks whether the window is currently maximized.
   *
   * @returns {boolean} True if the window is maximized, false otherwise.
   */
  isMaximized() {
    return this.#maximized;
  }

  /**
   * Requests the current window data from the main process.
   * The returned data includes window bounds, state, and other properties.
   *
   * @returns {Promise<WindowDataResult>} A promise that resolves with the current window data.
   */
  getWindowData() {
    return electron.ipcRenderer.invoke(this.#AppEvents.GetWindowData);
  }

  /**
   * Retrieves the amount of system idle time in seconds.
   * This represents how long the system has been idle (i.e., without any user input).
   *
   * @returns {Promise<number>} A promise that resolves with the number of seconds since last user activity.
   */
  systemIdleTime() {
    return electron.ipcRenderer.invoke(this.#AppEvents.SystemIdleTime);
  }

  /**
   * Determines the current system idle state.
   *
   * @param {number} idleThreshold
   *
   * @returns {Promise<"active" | "idle" | "locked" | "unknown">}
   */
  systemIdleState(idleThreshold) {
    return electron.ipcRenderer.invoke(this.#AppEvents.SystemIdleState, idleThreshold);
  }

  /**
   * Sends a request to the main process to update and resend the latest cache state.
   * @returns {Promise<Record<string, *>>}
   */
  async requestCache() {
    const result = await electron.ipcRenderer.invoke(this.#AppEvents.ElectronCacheValues);
    this.#setCache(result, true);
    return result;
  }

  /**
   * Sends a request to forcibly focus the application window, even if it’s not currently visible or active.
   * @returns {Promise<void>}
   */
  forceFocus() {
    return electron.ipcRenderer.invoke(this.#AppEvents.ForceFocusWindow);
  }

  /**
   * Brings the application window to the front and gives it focus.
   * @returns {Promise<void>}
   */
  focus() {
    return electron.ipcRenderer.invoke(this.#AppEvents.FocusWindow);
  }

  /**
   * Removes focus from the application window, if currently focused.
   * @returns {Promise<void>}
   */
  blur() {
    return electron.ipcRenderer.invoke(this.#AppEvents.BlurWindow);
  }

  /**
   * Makes the application window visible.
   * @returns {Promise<void>}
   */
  show() {
    return electron.ipcRenderer.invoke(this.#AppEvents.ShowWindow);
  }

  /**
   * Hides the application window from view (but does not quit the app).
   * @returns {Promise<void>}
   */
  hide() {
    return electron.ipcRenderer.invoke(this.#AppEvents.WindowHide);
  }

  /**
   * Closes the application window (but does not quit the app).
   * @returns {Promise<void>}
   */
  close() {
    return electron.ipcRenderer.invoke(this.#AppEvents.WindowClose);
  }

  /**
   * Destroy the application window (but does not quit the app).
   * @returns {Promise<void>}
   */
  destroy() {
    return electron.ipcRenderer.invoke(this.#AppEvents.WindowDestroy);
  }

  /**
   * Maximizes the application window to fill the screen.
   * @returns {Promise<void>}
   */
  maximize() {
    return electron.ipcRenderer.invoke(this.#AppEvents.WindowMaximize);
  }

  /**
   * Restores the application window from maximized state to its previous size.
   * @returns {Promise<void>}
   */
  unmaximize() {
    return electron.ipcRenderer.invoke(this.#AppEvents.WindowUnmaximize);
  }

  /**
   * Minimizes the application window to the taskbar/dock.
   * @returns {Promise<void>}
   */
  minimize() {
    return electron.ipcRenderer.invoke(this.#AppEvents.WindowMinimize);
  }

  /**
   * Sets whether the window can be maximized.
   * Sends an event to the renderer with the updated state.
   *
   * @param {boolean} value - If true, the window becomes maximizable; otherwise, it cannot be maximized.
   * @returns {Promise<boolean>} - Edit result.
   */
  setMaximizable(value) {
    return electron.ipcRenderer.invoke(this.#AppEvents.SetWindowIsMaximizable, value);
  }

  /**
   * Sets whether the window can be closed.
   * Sends an event to the renderer with the updated state.
   *
   * @param {boolean} value - If true, the window can be closed; otherwise, it cannot be closed.
   * @returns {Promise<boolean>} - Edit result.
   */
  setClosable(value) {
    return electron.ipcRenderer.invoke(this.#AppEvents.SetWindowIsClosable, value);
  }

  /**
   * Sets whether the window can be focused.
   * Sends an event to the renderer with the updated state.
   *
   * @param {boolean} value - If true, the window can be focused; otherwise, it cannot receive focus.
   * @returns {Promise<boolean>} - Edit result.
   */
  setFocusable(value) {
    return electron.ipcRenderer.invoke(this.#AppEvents.SetWindowIsFocusable, value);
  }

  /**
   * Sets whether the window can enter fullscreen mode.
   * Sends an event to the renderer with the updated state.
   *
   * @param {boolean} value - If true, the window can enter fullscreen mode; otherwise, it cannot receive focus.
   * @returns {Promise<boolean>} - Edit result.
   */
  setFullScreenable(value) {
    return electron.ipcRenderer.invoke(this.#AppEvents.SetWindowIsFullScreenable, value);
  }

  /**
   * Requests the application to quit immediately.
   *
   * @returns {void}
   */
  quit() {
    return electron.ipcRenderer.send(this.#AppEvents.AppQuit, true);
  }

  /**
   * Returns the absolute path to the current executable of the running application.
   *
   * @returns {string}
   */
  getExecPath() {
    return process.execPath;
  }

  /**
   * Sets the internal visibility flag.
   *
   * @param {boolean} [isVisible]
   * @returns {Promise<boolean>}
   */
  setIsVisible(isVisible) {
    return electron.ipcRenderer.invoke(this.#AppEvents.ToggleVisible, isVisible);
  }

  /**
   * Updates the application's network proxy settings.
   * Requires an Electron `ProxyConfig` object with appropriate options.
   *
   * @param {Electron.ProxyConfig} config
   * @returns {Promise<void>}
   */
  setProxy(config) {
    return electron.ipcRenderer.invoke(this.#AppEvents.SetProxy, config);
  }

  /**
   * Changes the tray icon to the specified image.
   * `img` should be a valid image file.
   *
   * @param {string} img
   * @param {string} key
   *
   * @returns {Promise<void>}
   */
  changeTrayIcon(img, key) {
    if (typeof img !== 'string')
      throw new TypeError('[changeTrayIcon] The img needs to be a string.');
    if (typeof key !== 'string')
      throw new TypeError('[changeTrayIcon] The key needs to be a string.');
    return electron.ipcRenderer.invoke(this.#AppEvents.ChangeTrayIcon, { img, key });
  }

  /**
   * Changes the application window or dock icon (depending on platform).
   *
   * @param {string} img
   * @returns {Promise<void>}
   */
  changeAppIcon(img) {
    if (typeof img !== 'string')
      throw new TypeError('[changeAppIcon] The img needs to be a string.');
    return electron.ipcRenderer.invoke(this.#AppEvents.ChangeAppIcon, img);
  }

  /**
   * Opens the Developer Tools (DevTools) for the window.
   *
   * This method triggers the main process to open the DevTools panel
   * with optional configuration.
   *
   * @param {Electron.OpenDevToolsOptions} [ops] - Optional settings to customize the behavior of DevTools.
   * Example options include `{ mode: 'undocked' }` or `{ mode: 'detach' }`.
   *
   * @returns {Promise<void>} A promise that resolves when the request is successfully sent.
   */
  openDevTools(ops) {
    return electron.ipcRenderer.invoke(this.#AppEvents.OpenDevTools, ops);
  }

  /**
   * Sets the window title for the BrowserWindow.
   *
   * This method sends an request to the main process to update the
   * window's title dynamically.
   *
   * @param {string} title - The new title to set. Must be a non-empty string.
   * @throws {TypeError} If the title is not a string.
   * @returns {Promise<void>} A promise that resolves when the title is set.
   */
  setTitle(title) {
    return electron.ipcRenderer.invoke(this.#AppEvents.SetTitle, title);
  }

  /**
   * @param {string} apiName - The name under which the API will be exposed in the window context.
   * @param {string[]} [enabledMethods] - Optional list of method names to include in the API. All methods are enabled by default.
   * @returns {Partial<TinyElectronClientApi>}
   */
  installWinScript(apiName = 'electronWindow', enabledMethods) {
    if (this.#exposeInMainWorld.length > 0)
      throw new Error(
        `[installWinScript] API '${this.#exposeInMainWorld}' is already exposed in the main world.`,
      );
    if (typeof apiName !== 'string' || apiName.length < 1)
      throw new Error('[installWinScript] apiName must be a non-empty string.');
    this.#exposeInMainWorld = apiName;

    /** @type {TinyElectronClientApi} */
    const apiTemplate = {
      on: (event, listener) => {
        this.on(event, listener);
      },
      off: (event, listener) => {
        this.on(event, listener);
      },
      once: (event, listener) => {
        this.once(event, listener);
      },
      openDevTools: (ops) => this.openDevTools(ops),
      setTitle: (title) => this.setTitle(title),

      getShowStatus: () => this.getShowStatus(),
      getData: () => this.getData(),
      getCache: () => this.getCache(),

      isVisible: () => this.isVisible(),
      isFocused: () => this.isFocused(),
      isMaximized: () => this.isMaximized(),
      isFullScreen: () => this.isFullScreen(),

      isMaximizable: () => this.isMaximizable(),
      isClosable: () => this.isClosable(),
      isFullScreenable: () => this.isFullScreenable(),
      isFocusable: () => this.isFocusable(),

      setMaximizable: (value) => this.setMaximizable(value),
      setClosable: (value) => this.setClosable(value),
      setFocusable: (value) => this.setFocusable(value),
      setFullScreenable: (value) => this.setFullScreenable(value),
      getWindowData: () => this.getWindowData(),

      requestCache: () => this.requestCache(),

      forceFocus: () => this.forceFocus(),
      focus: () => this.focus(),
      blur: () => this.blur(),

      getBounds: () => this.getBounds(),
      getPosition: () => this.getPosition(),
      getSize: () => this.getSize(),

      getChangeCount: (where) => this.getChangeCount(where),
      getAllChangeCount: () => this.getAllChangeCount(),

      show: () => this.show(),
      hide: () => this.hide(),
      close: () => this.close(),
      destroy: () => this.destroy(),

      maximize: () => this.maximize(),
      unmaximize: () => this.unmaximize(),
      minimize: () => this.minimize(),
      quit: () => this.quit(),

      systemIdleTime: () => this.systemIdleTime(),
      systemIdleState: (idleThreshold) => this.systemIdleState(idleThreshold),

      getExecPath: () => this.getExecPath(),

      changeTrayIcon: (img, id) => this.changeTrayIcon(img, id),
      changeAppIcon: (img) => this.changeAppIcon(img),

      setIsVisible: (isVisible) => this.setIsVisible(isVisible),

      setProxy: (config) => this.setProxy(config),
    };

    /** @type {Partial<TinyElectronClientApi>} */
    const api = {};

    if (!Array.isArray(enabledMethods)) {
      for (const name in apiTemplate) {
        // @ts-ignore
        api[name] = apiTemplate[name];
      }
    } else {
      for (const name of enabledMethods) {
        if (typeof name !== 'string')
          throw new TypeError(
            `[installWinScript] All values in enabledMethods must be strings. Found: ${typeof name}`,
          );

        // @ts-ignore
        if (typeof apiTemplate[name] === 'function') {
          // @ts-ignore
          api[name] = apiTemplate[name];
        } else {
          throw new Error(`[installWinScript] Invalid method name: "${name}"`);
        }
      }
    }

    electron.contextBridge.exposeInMainWorld(apiName, api);
    return api;
  }

  /**
   * Installs a loading page and exposes methods to control it via the main world context.
   *
   * @param {string} [exposeInMainWorld='electronLoading'] - The name of the property exposed in the window object via Electron’s `contextBridge`.
   * @param {GetLoadingHtml} [config] - Optional configuration for the loading screen, including custom HTML and CSS.
   * @returns {InstallLoadingPageResult} Object containing `append` and `remove` methods.
   *
   * @throws {TypeError} If `exposeInMainWorld` is provided but is not a string.
   */
  installLoadingPage(exposeInMainWorld = 'electronLoading', config) {
    if (typeof exposeInMainWorld !== 'undefined' && typeof exposeInMainWorld !== 'string')
      throw new TypeError(
        `Invalid key type "${typeof exposeInMainWorld}" of exposeInMainWorld. Only string keys are supported.`,
      );

    /**
     * Resolves when the DOM has reached a ready state included in the given list.
     *
     * @param {DocumentReadyState[]} [condition=['complete', 'interactive']] - List of acceptable document states to consider the DOM ready.
     * @returns {Promise<boolean>} Promise that resolves when DOM is ready.
     */
    function domReady(condition = ['complete', 'interactive']) {
      return new Promise((resolve) => {
        if (condition.includes(document.readyState)) {
          resolve(true);
        } else {
          document.addEventListener('readystatechange', () => {
            if (condition.includes(document.readyState)) {
              resolve(true);
            }
          });
        }
      });
    }

    /**
     * Utility object that provides safe methods for appending and removing
     * child elements from a parent, avoiding duplication or errors.
     */
    const safeDOM = {
      /**
       * Appends a child element to a parent if it is not already present.
       *
       * @param {HTMLElement} parent - The parent element to append to.
       * @param {HTMLElement} child - The child element to append.
       */
      append(parent, child) {
        if (!Array.from(parent.children).find((e) => e === child)) {
          return parent.appendChild(child);
        }
      },
      /**
       * Removes a child element from a parent if it exists.
       *
       * @param {HTMLElement} parent - The parent element to remove from.
       * @param {HTMLElement} child - The child element to remove.
       */
      remove(parent, child) {
        if (Array.from(parent.children).find((e) => e === child)) {
          return parent.removeChild(child);
        }
      },
    };

    /**
     * Initializes the loading screen and provides control methods for it.
     *
     * @returns {{ appendLoading: () => void, removeLoading: () => void }} Object with methods to append or remove the loading screen.
     */
    function useLoading() {
      const { oStyle, oDiv } = LoadingHtml.getLoadingHtml(config);
      return {
        appendLoading() {
          safeDOM.append(document.head, oStyle);
          safeDOM.append(document.body, oDiv);
        },
        removeLoading() {
          safeDOM.remove(document.head, oStyle);
          safeDOM.remove(document.body, oDiv);
        },
      };
    }

    // ----------------------------------------------------------------------

    const { appendLoading, removeLoading } = useLoading();
    if (typeof exposeInMainWorld === 'string') {
      /** @type {InstallLoadingPageResult} */
      const api = { append: appendLoading, remove: removeLoading };
      electron.contextBridge.exposeInMainWorld(exposeInMainWorld, api);
    }
    domReady().then(appendLoading);

    window.onmessage = (ev) => {
      ev.data.payload === 'removeLoading' && removeLoading();
    };

    return { append: appendLoading, remove: removeLoading };
  }

  /**
   * @param {Object} [settings={}] - Configuration settings for the application.
   * @param {AppEvents} [settings.eventNames=this.#AppEvents] - Set of event names for internal messaging.
   *
   * @throws {Error} If any required string values are missing or invalid.
   */
  constructor({ eventNames = { ...this.#AppEvents } } = {}) {
    Utils.checkEventsList(eventNames, this.#AppEvents);

    // Console warning
    electron.ipcRenderer.on(this.#AppEvents.ConsoleMessage, (_event, { value } = {}) =>
      console.log(value[0], value[1]),
    );

    /**
     * Helper to process received standard IPC events dynamically.
     *
     * @param {string} where - The event channel name.
     * @param {((value: any, useIt: boolean) => void)|null} callback - Action to trigger using payload data.
     * @param {{ value: any; time: number; }} data - The internal data bundle.
     * @param {string} [customPing] - Optional identifier for preventing out-of-order execution.
     */
    const sendEvent = (where, callback, data, customPing) => {
      const { value, time } = data;
      const pingId = customPing || where;
      if (typeof this.#eventPings[pingId] !== 'number') this.#eventPings[pingId] = 0;
      if (
        typeof time === 'number' &&
        Number.isFinite(time) &&
        !Number.isNaN(time) &&
        time > this.#eventPings[pingId]
      ) {
        this.#eventPings[pingId] = time;
        if (typeof callback === 'function') callback(value, true);
        this.#emit(where, value);
      } else if (typeof callback === 'function') callback(value, false);
    };

    // Ready to Show
    electron.ipcRenderer.on(this.#AppEvents.ReadyToShow, (_event, data) =>
      sendEvent(Events.RootEvents.ReadyToShow, null, data),
    );

    // Resize
    electron.ipcRenderer.on(this.#AppEvents.Resize, (_event, data) =>
      sendEvent(Events.RootEvents.Resize, (value, useIt) => this.#setSize(value, useIt), data, 'resize'),
    );

    electron.ipcRenderer.on(this.#AppEvents.Resized, (_event, data) =>
      sendEvent(Events.RootEvents.Resized, (value, useIt) => this.#setSize(value, useIt), data, 'resize'),
    );

    electron.ipcRenderer.on(this.#AppEvents.WillResize, (_event, data) =>
      sendEvent(
        Events.RootEvents.WillResize,
        (value, useIt) => this.#setSize(value, useIt),
        data,
        'resize',
      ),
    );

    // Proxy
    electron.ipcRenderer.on(this.#AppEvents.SetProxy, (_event, data) =>
      sendEvent(Events.RootEvents.SetProxy, null, data),
    );

    electron.ipcRenderer.on(this.#AppEvents.SetProxyError, (_event, { err }) => {
      try {
        /** @type {Error} */
        const newErr = Utils.deserializeError(err);
        this.#emit(Events.RootEvents.SetProxyError, newErr);
      } catch (newErr) {
        console.error(newErr);
      }
    });

    // App Status
    electron.ipcRenderer.on(this.#AppEvents.ShowApp, (_event, data) =>
      sendEvent(Events.RootEvents.ShowApp, (value, useIt) => this.#setShowStatus(value, useIt), data),
    );

    // Move
    electron.ipcRenderer.on(this.#AppEvents.WindowMove, (_event, arg) =>
      sendEvent(Events.RootEvents.WindowMove, (value, useIt) => this.#setPosition(value, useIt), arg),
    );

    // Maximize
    electron.ipcRenderer.on(this.#AppEvents.WindowIsMaximized, (_event, arg) =>
      sendEvent(Events.RootEvents.IsMaximized, (value, useIt) => this.#setIsMaximized(value, useIt), arg),
    );

    // Focus
    electron.ipcRenderer.on(this.#AppEvents.WindowIsFocused, (_event, arg) =>
      sendEvent(Events.RootEvents.IsFocused, (value, useIt) => this.#setIsFocused(value, useIt), arg),
    );

    // Visible
    electron.ipcRenderer.on(this.#AppEvents.WindowIsVisible, (_event, arg) =>
      sendEvent(Events.RootEvents.IsVisible, (value, useIt) => this.#setIsVisible(value, useIt), arg),
    );

    // Full Screen
    electron.ipcRenderer.on(this.#AppEvents.WindowIsFullScreen, (_event, arg) =>
      sendEvent(
        Events.RootEvents.IsFullScreen,
        (value, useIt) => this.#setIsFullScreen(value, useIt),
        arg,
      ),
    );

    // Ping
    electron.ipcRenderer.on(this.#AppEvents.Ping, (_event, arg) =>
      sendEvent(Events.RootEvents.Ping, (value, useIt) => this.#firstPing(value, useIt), arg),
    );

    // Is Maximizable
    electron.ipcRenderer.on(this.#AppEvents.WindowIsMaximizable, (_event, arg) =>
      sendEvent(
        Events.RootEvents.IsMaximizable,
        (value, useIt) => this.#setIsMaximizable(value, useIt),
        arg,
      ),
    );

    // Is Closable
    electron.ipcRenderer.on(this.#AppEvents.WindowIsClosable, (_event, arg) =>
      sendEvent(Events.RootEvents.IsClosable, (value, useIt) => this.#setIsClosable(value, useIt), arg),
    );

    // Is FullScreenable
    electron.ipcRenderer.on(this.#AppEvents.WindowIsFullScreenable, (_event, arg) =>
      sendEvent(
        Events.RootEvents.IsFullScreenable,
        (value, useIt) => this.#setIsFullScreenable(value, useIt),
        arg,
      ),
    );

    // Is Focusable
    electron.ipcRenderer.on(this.#AppEvents.WindowIsFocusable, (_event, arg) =>
      sendEvent(Events.RootEvents.IsFocusable, (value, useIt) => this.#setIsFocusable(value, useIt), arg),
    );

    const getConfig = electron.ipcRenderer.invoke(this.#AppEvents.GetWindowData);
    const domContentLoaded = new Promise((resolve) => {
      if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', resolve);
      else resolve(null);
    });

    const windowFirstConfig = {
      /** @type {Bounds|null} */
      bounds: null,
      /** @type {boolean|null} */
      isFullScreen: null,
      /** @type {boolean|null} */
      isFocused: null,
      /** @type {boolean|null} */
      isMaximized: null,
      /** @type {boolean|null} */
      isMaximizable: null,
      /** @type {boolean|null} */
      isClosable: null,
      /** @type {boolean|null} */
      isFullScreenable: null,
      /** @type {boolean|null} */
      isFocusable: null,
    };

    getConfig.then(
      /** @param {WindowDataResult} data */ (data) => {
        if (!data) return data;
        if (
          typeof data.bounds === 'object' &&
          typeof data.bounds.x === 'number' &&
          typeof data.bounds.y === 'number' &&
          typeof data.bounds.height === 'number' &&
          typeof data.bounds.width === 'number'
        )
          windowFirstConfig.bounds = {
            height: data.bounds.height,
            width: data.bounds.width,
            x: data.bounds.x,
            y: data.bounds.y,
          };

        if (typeof data.isFocused === 'boolean') windowFirstConfig.isFocused = data.isFocused;
        if (typeof data.isFullScreen === 'boolean')
          windowFirstConfig.isFullScreen = data.isFullScreen;
        if (typeof data.isMaximized === 'boolean') windowFirstConfig.isMaximized = data.isMaximized;
        if (typeof data.isMaximizable === 'boolean')
          windowFirstConfig.isMaximizable = data.isMaximizable;
        if (typeof data.isClosable === 'boolean') windowFirstConfig.isClosable = data.isClosable;
        if (typeof data.isFullScreenable === 'boolean')
          windowFirstConfig.isFullScreenable = data.isFullScreenable;
        if (typeof data.isFocusable === 'boolean') windowFirstConfig.isFocusable = data.isFocusable;
      },
    );

    // Ready
    Promise.all([getConfig, domContentLoaded]).then(() => {
      // Get entries
      const entries = performance.getEntriesByType('navigation');
      const type =
        // @ts-ignore
        entries.length > 0 && typeof entries[0].type === 'string' ? entries[0].type : null;

      // Fix values
      if (windowFirstConfig.bounds !== null && typeof windowFirstConfig.bounds === 'object') {
        if (
          this.getChangeCount('position') < 1 &&
          typeof windowFirstConfig.bounds.x === 'number' &&
          typeof windowFirstConfig.bounds.y === 'number'
        )
          this.#setPosition([windowFirstConfig.bounds.x, windowFirstConfig.bounds.y], true);
        if (
          this.getChangeCount('size') < 1 &&
          typeof windowFirstConfig.bounds.width === 'number' &&
          typeof windowFirstConfig.bounds.height === 'number'
        )
          this.#setSize([windowFirstConfig.bounds.width, windowFirstConfig.bounds.height], true);
      }

      if (this.getChangeCount('isFocused') < 1 && typeof windowFirstConfig.isFocused === 'boolean')
        this.#setIsFocused(windowFirstConfig.isFocused, true);

      if (
        this.getChangeCount('isFullScreen') < 1 &&
        typeof windowFirstConfig.isFullScreen === 'boolean'
      )
        this.#setIsFullScreen(windowFirstConfig.isFullScreen, true);

      if (
        this.getChangeCount('isMaximized') < 1 &&
        typeof windowFirstConfig.isMaximized === 'boolean'
      )
        this.#setIsMaximized(windowFirstConfig.isMaximized, true);

      if (
        this.getChangeCount('isMaximizable') < 1 &&
        typeof windowFirstConfig.isMaximizable === 'boolean'
      )
        this.#setIsMaximizable(windowFirstConfig.isMaximizable, true);

      if (
        this.getChangeCount('isClosable') < 1 &&
        typeof windowFirstConfig.isClosable === 'boolean'
      )
        this.#setIsClosable(windowFirstConfig.isClosable, true);

      if (
        this.getChangeCount('isFullScreenable') < 1 &&
        typeof windowFirstConfig.isFullScreenable === 'boolean'
      )
        this.#setIsFullScreenable(windowFirstConfig.isFullScreenable, true);

      if (
        this.getChangeCount('isFocusable') < 1 &&
        typeof windowFirstConfig.isFocusable === 'boolean'
      )
        this.#setIsFocusable(windowFirstConfig.isFocusable, true);

      // Start now
      electron.ipcRenderer.send(this.#AppEvents.DOMContentLoaded, { type, ...windowFirstConfig });
      this.#emit(Events.RootEvents.Ready, { type, ...windowFirstConfig });
    });
  }
}

module.exports = TinyElectronClient;