UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

600 lines (530 loc) 17.4 kB
// @ts-check /** * @module window * * Provides ApplicationWindow class and methods * * Don't use this module directly, get instances of ApplicationWindow with * `socket:application` methods like `getCurrentWindow`, `createWindow`, * `getWindow`, and `getWindows`. */ import { isValidPercentageValue } from './util.js' import * as statuses from './window/constants.js' import location from './location.js' import { URL } from './url.js' import client from './application/client.js' import hotkey from './window/hotkey.js' import menu from './application/menu.js' import ipc from './ipc.js' /** * @param {string} url * @return {string} * @ignore */ export function formatURL (url) { const href = location?.href ?? 'socket:///' return String(new URL(url, href)) } /** * @class ApplicationWindow * Represents a window in the application */ export class ApplicationWindow { #id = null #index #options #channel = null #senderWindowIndex = globalThis.__args.index #listeners = {} // TODO(@chicoxyzzy): add parent and children? (needs native process support) static constants = statuses static hotkey = hotkey constructor ({ index, ...options }) { this.#id = options?.id this.#index = index this.#options = options this.#channel = new BroadcastChannel(`socket.runtime.window.${this.#index}`) } #updateOptions (response) { const { data, err } = response if (err) { throw new Error(err) } const { id, index, ...options } = data this.#id = id ?? null this.#options = options return data } /** * The unique ID of this window. * @type {string} */ get id () { return this.#id } /** * Get the index of the window * @return {number} - the index of the window */ get index () { return this.#index } /** * @type {import('./window/hotkey.js').default} */ get hotkey () { return hotkey } /** * The broadcast channel for this window. * @type {BroadcastChannel} */ get channel () { return this.#channel } /** * Get the size of the window * @return {{ width: number, height: number }} - the size of the window */ getSize () { return { width: this.#options.width, height: this.#options.height } } /** * Get the position of the window * @return {{ x: number, y: number }} - the position of the window */ getPosition () { return { x: this.#options.x, y: this.#options.y } } /** * Get the title of the window * @return {string} - the title of the window */ getTitle () { return this.#options.title } /** * Get the status of the window * @return {string} - the status of the window */ getStatus () { return this.#options.status } /** * Close the window * @return {Promise<object>} - the options of the window */ async close () { const { data, err } = await ipc.request('window.close', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) if (err) { throw err } return data } /** * Shows the window * @return {Promise<ipc.Result>} */ async show () { const response = await ipc.request('window.show', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) return this.#updateOptions(response) } /** * Hides the window * @return {Promise<ipc.Result>} */ async hide () { const response = await ipc.request('window.hide', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) return this.#updateOptions(response) } /** * Maximize the window * @return {Promise<ipc.Result>} */ async maximize () { const response = await ipc.request('window.maximize', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) return this.#updateOptions(response) } /** * Minimize the window * @return {Promise<ipc.Result>} */ async minimize () { const response = await ipc.request('window.minimize', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) return this.#updateOptions(response) } /** * Restore the window * @return {Promise<ipc.Result>} */ async restore () { const response = await ipc.request('window.restore', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) return this.#updateOptions(response) } /** * Sets the title of the window * @param {string} title - the title of the window * @return {Promise<ipc.Result>} */ async setTitle (title) { const response = await ipc.request('window.setTitle', { index: this.#senderWindowIndex, targetWindowIndex: this.#index, value: title }) return this.#updateOptions(response) } /** * Sets the size of the window * @param {object} opts - an options object * @param {(number|string)=} opts.width - the width of the window * @param {(number|string)=} opts.height - the height of the window * @return {Promise<ipc.Result>} * @throws {Error} - if the width or height is invalid */ async setSize (opts) { // default values const options = { targetWindowIndex: this.#index, index: this.#senderWindowIndex } if ((opts.width != null && typeof opts.width !== 'number' && typeof opts.width !== 'string') || (typeof opts.width === 'string' && !isValidPercentageValue(opts.width)) || (typeof opts.width === 'number' && !(Number.isInteger(opts.width) && opts.width > 0))) { throw new Error(`Window width must be an integer number or a string with a valid percentage value from 0 to 100 ending with %. Got ${opts.width} instead.`) } if (typeof opts.width === 'string' && isValidPercentageValue(opts.width)) { options.width = opts.width } if (typeof opts.width === 'number') { options.width = opts.width.toString() } if ((opts.height != null && typeof opts.height !== 'number' && typeof opts.height !== 'string') || (typeof opts.height === 'string' && !isValidPercentageValue(opts.height)) || (typeof opts.height === 'number' && !(Number.isInteger(opts.height) && opts.height > 0))) { throw new Error(`Window height must be an integer number or a string with a valid percentage value from 0 to 100 ending with %. Got ${opts.height} instead.`) } if (typeof opts.height === 'string' && isValidPercentageValue(opts.height)) { options.height = opts.height } if (typeof opts.height === 'number') { options.height = opts.height.toString() } const response = await ipc.request('window.setSize', options) return this.#updateOptions(response) } /** * Sets the position of the window * @param {object} opts - an options object * @param {(number|string)=} opts.x - the x position of the window * @param {(number|string)=} opts.y - the y position of the window * @return {Promise<object>} * @throws {Error} - if the x or y is invalid */ async setPosition (opts) { // default values const options = { targetWindowIndex: this.#index, index: this.#senderWindowIndex } if ((opts.x != null && typeof opts.x !== 'number' && typeof opts.x !== 'string') || (typeof opts.x === 'string' && !isValidPercentageValue(opts.x)) || (typeof opts.x === 'number' && !(Number.isInteger(opts.x) && opts.x > 0))) { throw new Error(`Window x must be an integer number or a string with a valid percentage value from 0 to 100 ending with %. Got ${opts.x} instead.`) } if (typeof opts.x === 'string' && isValidPercentageValue(opts.x)) { options.x = opts.x } if (typeof opts.x === 'number') { options.x = opts.x.toString() } if ((opts.y != null && typeof opts.y !== 'number' && typeof opts.y !== 'string') || (typeof opts.y === 'string' && !isValidPercentageValue(opts.y)) || (typeof opts.y === 'number' && !(Number.isInteger(opts.y) && opts.y > 0))) { throw new Error(`Window y must be an integer number or a string with a valid percentage value from 0 to 100 ending with %. Got ${opts.y} instead.`) } if (typeof opts.y === 'string' && isValidPercentageValue(opts.y)) { options.y = opts.y } if (typeof opts.y === 'number') { options.y = opts.y.toString() } const response = await ipc.request('window.setPosition', options) return this.#updateOptions(response) } /** * Navigate the window to a given path * @param {object} path - file path * @return {Promise<ipc.Result>} */ async navigate (path) { const response = await ipc.request('window.navigate', { index: this.#senderWindowIndex, targetWindowIndex: this.#index, url: formatURL(path) }) return this.#updateOptions(response) } /** * Opens the Web Inspector for the window * @return {Promise<object>} */ async showInspector () { const { data, err } = await ipc.request('window.showInspector', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) if (err) { throw err } return data } /** * Sets the background color of the window * @param {object} opts - an options object * @param {number} opts.red - the red value * @param {number} opts.green - the green value * @param {number} opts.blue - the blue value * @param {number} opts.alpha - the alpha value * @return {Promise<object>} */ async setBackgroundColor (opts) { const response = await ipc.request('window.setBackgroundColor', { index: this.#senderWindowIndex, targetWindowIndex: this.#index, ...opts }) return this.#updateOptions(response) } /** * Gets the background color of the window * @return {Promise<object>} */ async getBackgroundColor () { return await ipc.request('window.getBackgroundColor', { index: this.#senderWindowIndex, targetWindowIndex: this.#index }) } /** * Opens a native context menu. * @param {object} options - an options object * @return {Promise<object>} */ async setContextMenu (options) { return await menu.context.set(options) } /** * Shows a native open file dialog. * @param {object} options - an options object * @return {Promise<string[]>} - an array of file paths */ async showOpenFilePicker (options) { const result = await ipc.request('window.showFileSystemPicker', { type: 'open', ...options }) if (result.err) { throw result.err } return result.data.paths } /** * Shows a native save file dialog. * @param {object} options - an options object * @return {Promise<string[]>} - an array of file paths */ async showSaveFilePicker (options) { const result = await ipc.request('window.showFileSystemPicker', { type: 'save', ...options }) if (result.err) { throw result.err } return result.data.paths[0] ?? null } /** * Shows a native directory dialog. * @param {object} options - an options object * @return {Promise<string[]>} - an array of file paths */ async showDirectoryFilePicker (options) { const result = await ipc.request('window.showFileSystemPicker', { type: 'open', allowDirs: true, ...options }) if (result.err) { throw result.err } return result.data.paths } /** * This is a high-level API that you should use instead of `ipc.request` when * you want to send a message to another window or to the backend. * * @param {object} options - an options object * @param {number=} options.window - the window to send the message to * @param {boolean=} [options.backend = false] - whether to send the message to the backend * @param {string} options.event - the event to send * @param {(string|object)=} options.value - the value to send * @returns */ async send (options) { if (this.#index !== this.#senderWindowIndex) { throw new Error('window.send can only be used from the current window') } if (!Number.isInteger(options.window) && options.backend !== true) { throw new Error('window should be an integer') } if (options.backend === true && options.window != null) { throw new Error('backend option cannot be used together with window option') } if (typeof options.event !== 'string' || options.event.length === 0) { throw new Error('event should be a non-empty string') } const value = typeof options.value !== 'string' ? JSON.stringify(options.value) : options.value if (options.backend === true) { return await ipc.request('process.write', { index: this.#senderWindowIndex, event: options.event, value: value !== undefined ? JSON.stringify(value) : null }) } return await ipc.request('window.send', { index: this.#senderWindowIndex, targetWindowIndex: options.window, event: options.event, value: encodeURIComponent(value) }) } /** * Post a message to a window * TODO(@jwerle): research using `BroadcastChannel` instead * @param {object} message * @return {Promise} */ async postMessage (message) { if (this.#index === this.#senderWindowIndex) { globalThis.dispatchEvent(new MessageEvent('message', message)) } else { return await ipc.request('window.send', { index: this.#senderWindowIndex, targetWindowIndex: this.#index, event: 'message', value: encodeURIComponent(message.data) }) } } /** * Opens an URL in the default application associated with the URL protocol, * such as 'https:' for the default web browser. * @param {string} value * @returns {Promise<{ url: string }>} */ async openExternal (value) { const result = await ipc.request('platform.openExternal', value) if (result.err) { throw result.err } return result.data } /** * Opens a file in the default file explorer. * @param {string} value * @returns {Promise} */ async revealFile (value) { const result = await ipc.request('platform.revealFile', value) if (result.err) { throw result.err } } // public EventEmitter methods /** * Adds a listener to the window. * @param {string} event - the event to listen to * @param {function(*): void} cb - the callback to call * @returns {void} */ addListener (event, cb) { if (this.#index !== this.#senderWindowIndex) { throw new Error('window.addListener can only be used from the current window') } if (!(event in this.#listeners)) { this.#listeners[event] = [] } this.#listeners[event].push(cb) globalThis.addEventListener(event, cb) } /** * Adds a listener to the window. An alias for `addListener`. * @param {string} event - the event to listen to * @param {function(*): void} cb - the callback to call * @returns {void} * @see addListener */ on (event, cb) { if (this.#index !== this.#senderWindowIndex) { throw new Error('window.on can only be used from the current window') } if (!(event in this.#listeners)) { this.#listeners[event] = [] } this.#listeners[event].push(cb) globalThis.addEventListener(event, cb) } /** * Adds a listener to the window. The listener is removed after the first call. * @param {string} event - the event to listen to * @param {function(*): void} cb - the callback to call * @returns {void} */ once (event, cb) { if (this.#index !== this.#senderWindowIndex) { throw new Error('window.once can only be used from the current window') } if (!(event in this.#listeners)) { this.#listeners[event] = [] } globalThis.addEventListener(event, cb, { once: true }) } /** * Removes a listener from the window. * @param {string} event - the event to remove the listener from * @param {function(*): void} cb - the callback to remove * @returns {void} */ removeListener (event, cb) { if (this.#index !== this.#senderWindowIndex) { throw new Error('window.removeListener can only be used from the current window') } this.#listeners[event] = this.#listeners[event].filter(listener => listener !== cb) globalThis.removeEventListener(event, cb) } /** * Removes all listeners from the window. * @param {string} event - the event to remove the listeners from * @returns {void} */ removeAllListeners (event) { if (this.#index !== this.#senderWindowIndex) { throw new Error('window.removeAllListeners can only be used from the current window') } for (const cb of this.#listeners[event]) { globalThis.removeEventListener(event, cb) } } /** * Removes a listener from the window. An alias for `removeListener`. * @param {string} event - the event to remove the listener from * @param {function(*): void} cb - the callback to remove * @returns {void} * @see removeListener */ off (event, cb) { if (this.#index !== this.#senderWindowIndex) { throw new Error('window.off can only be used from the current window') } this.#listeners[event] = this.#listeners[event].filter(listener => listener !== cb) globalThis.removeEventListener(event, cb) } } export { client, hotkey } export default ApplicationWindow /** * @ignore */ export const constants = statuses