@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.
633 lines (551 loc) • 18.7 kB
JavaScript
/* global Event, MessageEvent */
// @ts-check
/**
* @module application
*
* Provides Application level methods
*
* Example usage:
* ```js
* import { createWindow } from 'socket:application'
* ```
*/
import { ApplicationURLEvent } from './internal/events.js'
import ApplicationWindow, { formatURL } from './window.js'
import { isValidPercentageValue } from './util.js'
import ipc, { primordials } from './ipc.js'
import menu, { setMenu } from './application/menu.js'
import client from './application/client.js'
import hooks from './hooks.js'
import os from './os.js'
import * as exports from './application.js'
const eventTarget = new EventTarget()
let isApplicationPaused = false
hooks.onApplicationResume((event) => {
isApplicationPaused = false
eventTarget.dispatchEvent(new Event(event.type, event))
})
hooks.onApplicationPause((event) => {
isApplicationPaused = true
eventTarget.dispatchEvent(new Event(event.type, event))
})
hooks.onApplicationURL((event) => {
eventTarget.dispatchEvent(new ApplicationURLEvent(event.type, event))
})
hooks.onMessage((event) => {
eventTarget.dispatchEvent(new MessageEvent(event.type, event))
})
function serializeConfig (config) {
if (!config || typeof config !== 'object') {
return ''
}
const entries = []
for (const key in config) {
entries.push(`${key} = ${config[key]}`)
}
return entries.join('\n')
}
export { client, menu }
// get this from constant value in runtime
export const MAX_WINDOWS = 32
export class ApplicationWindowList {
#list = []
static from (...args) {
if (Array.isArray(args[0])) {
return new this(args[0])
}
return new this(args)
}
constructor (items) {
if (Array.isArray(items)) {
for (const item of items) {
this.add(item)
}
}
}
get length () {
return this.#list.length
}
get size () {
return this.length
}
get [Symbol.iterator] () {
return this.#list[Symbol.iterator]
}
forEach (callback, thisArg) {
this.#list.forEach(callback, thisArg)
}
item (index) {
return this[index] ?? undefined
}
entries () {
const entries = []
for (const item of this.#list) {
entries.push([item.index, item])
}
return entries
}
keys () {
return this.entries().map((entry) => entry[0])
}
values () {
return this.entries().map((entry) => entry[1])
}
add (window) {
if (Number.isFinite(window.index) && window.index > -1) {
this[window.index] = window
for (let i = 0; i < this.#list.length; ++i) {
if (this.#list[i].index === window.index) {
this.#list.splice(i, 1)
break
}
}
this.#list.push(window)
this.#list.sort((a, b) => a.index - b.index)
}
return this
}
remove (windowOrIndex) {
let index = -1
if (Number.isFinite(windowOrIndex) && windowOrIndex > -1) {
index = windowOrIndex
} else {
index = windowOrIndex?.index ?? -1
}
if (index > -1) {
delete this[index]
for (let i = 0; i < this.#list.length; ++i) {
if (this.#list[i].index === index) {
this.#list.splice(i, 1)
return true
}
}
}
return false
}
contains (windowOrIndex) {
let index = -1
if (Number.isFinite(windowOrIndex) && windowOrIndex > -1) {
index = windowOrIndex
} else {
index = windowOrIndex?.index ?? -1
}
if (index > -1) {
return Boolean(this[index])
}
return false
}
clear () {
for (const item of this.#list) {
delete this[item.index]
}
this.#list = []
return this
}
}
/**
* Add an application event `type` callback `listener` with `options`.
* @param {string} type
* @param {function(Event|MessageEvent|CustomEvent|ApplicationURLEvent): boolean} listener
* @param {{ once?: boolean }|boolean=} [options]
*/
export function addEventListener (type, listener, options = null) {
return eventTarget.addEventListener(type, listener, options)
}
/**
* Remove an application event `type` callback `listener` with `options`.
* @param {string} type
* @param {function(Event|MessageEvent|CustomEvent|ApplicationURLEvent): boolean} listener
*/
export function removeEventListener (type, listener) {
return eventTarget.removeEventListener(type, listener)
}
/**
* Returns the current window index
* @return {number}
*/
export function getCurrentWindowIndex () {
return globalThis.__args.index ?? 0
}
/**
* Creates a new window and returns an instance of ApplicationWindow.
* @param {object} opts - an options object
* @param {string=} opts.aspectRatio - a string (split on ':') provides two float values which set the window's aspect ratio.
* @param {boolean=} opts.closable - deterime if the window can be closed.
* @param {boolean=} opts.minimizable - deterime if the window can be minimized.
* @param {boolean=} opts.maximizable - deterime if the window can be maximized.
* @param {number} [opts.margin] - a margin around the webview. (Private)
* @param {number} [opts.radius] - a radius on the webview. (Private)
* @param {number} opts.index - the index of the window.
* @param {string} opts.path - the path to the HTML file to load into the window.
* @param {string=} opts.title - the title of the window.
* @param {string=} opts.titlebarStyle - determines the style of the titlebar (MacOS only).
* @param {string=} opts.windowControlOffsets - a string (split on 'x') provides the x and y position of the traffic lights (MacOS only).
* @param {string=} opts.backgroundColorDark - determines the background color of the window in dark mode.
* @param {string=} opts.backgroundColorLight - determines the background color of the window in light mode.
* @param {(number|string)=} opts.width - the width of the window. If undefined, the window will have the main window width.
* @param {(number|string)=} opts.height - the height of the window. If undefined, the window will have the main window height.
* @param {(number|string)=} [opts.minWidth = 0] - the minimum width of the window
* @param {(number|string)=} [opts.minHeight = 0] - the minimum height of the window
* @param {(number|string)=} [opts.maxWidth = '100%'] - the maximum width of the window
* @param {(number|string)=} [opts.maxHeight = '100%'] - the maximum height of the window
* @param {boolean=} [opts.resizable=true] - whether the window is resizable
* @param {boolean=} [opts.frameless=false] - whether the window is frameless
* @param {boolean=} [opts.utility=false] - whether the window is utility (macOS only)
* @param {boolean=} [opts.shouldExitApplicationOnClose=false] - whether the window can exit the app
* @param {boolean=} [opts.headless=false] - whether the window will be headless or not (no frame)
* @param {string=} [opts.userScript=null] - A user script that will be injected into the window (desktop only)
* @param {string[]=} [opts.protocolHandlers] - An array of protocol handler schemes to register with the new window (requires service worker)
* @return {Promise<ApplicationWindow>}
*/
export async function createWindow (opts) {
if (typeof opts?.path !== 'string' || typeof opts?.index !== 'number') {
throw new Error('Path and index are required options')
}
// default values
const options = {
targetWindowIndex: opts.index,
url: formatURL(opts.path),
index: globalThis.__args.index,
title: opts.title ?? '',
resizable: opts.resizable ?? true,
closable: opts.closable === true,
maximizable: opts.maximizable ?? true,
minimizable: opts.minimizable ?? true,
frameless: opts.frameless ?? false,
aspectRatio: opts.aspectRatio ?? '',
titlebarStyle: opts.titlebarStyle ?? '',
windowControlOffsets: opts.windowControlOffsets ?? '',
backgroundColorDark: opts.backgroundColorDark ?? '',
backgroundColorLight: opts.backgroundColorLight ?? '',
utility: opts.utility ?? false,
shouldExitApplicationOnClose: opts.shouldExitApplicationOnClose ?? false,
/**
* @private
* @type {number}
*/
radius: opts.radius ?? 0,
/**
* @private
* @type {number}
*/
margin: opts.margin ?? 0,
minWidth: opts.minWidth ?? 0,
minHeight: opts.minHeight ?? 0,
maxWidth: opts.maxWidth ?? '100%',
maxHeight: opts.maxHeight ?? '100%',
headless: opts.headless === true,
// @ts-ignore
debug: opts.debug === true, // internal
userScript: encodeURIComponent(opts.userScript ?? ''),
// @ts-ignore
__runtime_primordial_overrides__: (
// @ts-ignore
opts.__runtime_primordial_overrides__ &&
// @ts-ignore
typeof opts.__runtime_primordial_overrides__ === 'object'
// @ts-ignore
? JSON.stringify(opts.__runtime_primordial_overrides__)
: ''
),
// @ts-ignore
config: typeof opts?.config === 'string'
// @ts-ignore
? opts.config
// @ts-ignore
: (serializeConfig(opts?.config) ?? '')
}
if (Array.isArray(opts?.protocolHandlers)) {
for (const protocolHandler of opts.protocolHandlers) {
// @ts-ignore
opts.config[`webview_protocol-handlers_${protocolHandler}`] = ''
}
} else if (opts?.protocolHandlers && typeof opts.protocolHandlers === 'object') {
// @ts-ignore
for (const key in opts.protocolHandlers) {
// @ts-ignore
if (opts.protocolHandlers[key] && typeof opts.protocolHandlers[key] === 'object') {
// @ts-ignore
opts.config[`webview_protocol-handlers_${key}`] = JSON.stringify(opts.protocolHandlers[key])
// @ts-ignore
} else if (typeof opts.protocolHandlers[key] === 'string') {
// @ts-ignore
opts.config[`webview_protocol-handlers_${key}`] = opts.protocolHandlers[key]
}
}
}
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 { data, err } = await ipc.request('window.create', options)
if (err) {
throw err
}
return new ApplicationWindow(data)
}
/**
* Returns the current screen size.
* @returns {Promise<{ width: number, height: number }>}
*/
export async function getScreenSize () {
if (os.platform() === 'android' || os.platform() === 'ios') {
return {
width: globalThis.screen?.availWidth ?? 0,
height: globalThis.screen?.availHeight ?? 0
}
}
const result = await ipc.request('application.getScreenSize', {
index: globalThis.__args.index
})
if (result.err) {
throw result.err
}
return result.data
}
function throwOnInvalidIndex (index) {
if (index === undefined || typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
throw new Error(`Invalid window index: ${index} (must be a positive integer number)`)
}
}
/**
* Returns the ApplicationWindow instances for the given indices or all windows if no indices are provided.
* @param {number[]} [indices] - the indices of the windows
* @throws {Error} - if indices is not an array of integer numbers
* @return {Promise<ApplicationWindowList>}
*/
export async function getWindows (indices, options = null) {
if (globalThis.RUNTIME_APPLICATION_ALLOW_MULTI_WINDOWS === false) {
return new ApplicationWindowList([
new ApplicationWindow({
index: 0,
id: globalThis.__args?.client?.id ?? null,
width: globalThis.screen?.availWidth ?? 0,
height: globalThis.screen?.availHeight ?? 0,
title: globalThis.document.title,
status: 31
})
])
}
// TODO: create a local registry and return from it when possible
const resultIndices = indices ?? []
if (!Array.isArray(resultIndices)) {
throw new Error('Indices list must be an array of integer numbers')
}
for (const index of resultIndices) {
throwOnInvalidIndex(index)
}
const result = await ipc.request('application.getWindows', resultIndices)
if (result.err) {
throw result.err
}
// 0 indexed based key to `ApplicationWindow` object map
const windows = new ApplicationWindowList()
if (!Array.isArray(result.data)) {
return windows
}
for (const data of result.data) {
const max = Number.isFinite(options?.max) ? options.max : MAX_WINDOWS
if (options?.max === false || data.index < max) {
windows.add(new ApplicationWindow(data))
}
}
return windows
}
/**
* Returns the ApplicationWindow instance for the given index
* @param {number} index - the index of the window
* @throws {Error} - if index is not a valid integer number
* @returns {Promise<ApplicationWindow>} - the ApplicationWindow instance or null if the window does not exist
*/
export async function getWindow (index, options) {
throwOnInvalidIndex(index)
const windows = await getWindows([index], options)
return windows[index]
}
/**
* Returns the ApplicationWindow instance for the current window.
* @return {Promise<ApplicationWindow>}
*/
export async function getCurrentWindow () {
return await getWindow(globalThis.__args.index, { max: false })
}
/**
* Quits the backend process and then quits the render process, the exit code used is the final exit code to the OS.
* @param {number} [code = 0] - an exit code
* @return {Promise<ipc.Result>}
*/
export async function exit (code = 0) {
const { data, err } = await ipc.request('application.exit', code)
if (err) {
throw err
}
return data
}
/**
* Set the native menu for the app.
*
* @param {object} options - an options object
* @param {string} options.value - the menu layout
* @param {number} options.index - the window to target (if applicable)
* @return {Promise<ipc.Result>}
*
* Socket Runtime provides a minimalist DSL that makes it easy to create
* cross platform native system and context menus.
*
* Menus are created at run time. They can be created from either the Main or
* Render process. The can be recreated instantly by calling the `setSystemMenu` method.
*
* The method takes a string. Here's an example of a menu. The semi colon is
* significant indicates the end of the menu. Use an underscore when there is no
* accelerator key. Modifiers are optional. And well known OS menu options like
* the edit menu will automatically get accelerators you dont need to specify them.
*
*
* ```js
* socket.application.setSystemMenu({ index: 0, value: `
* App:
* Foo: f;
*
* Edit:
* Cut: x
* Copy: c
* Paste: v
* Delete: _
* Select All: a;
*
* Other:
* Apple: _
* Another Test: T
* !Im Disabled: I
* Some Thing: S + Meta
* ---
* Bazz: s + Meta, Control, Alt;
* `)
* ```
*
* Separators
*
* To create a separator, use three dashes `---`.
*
*
* Accelerator Modifiers
*
* Accelerator modifiers are used as visual indicators but don't have a
* material impact as the actual key binding is done in the event listener.
*
* A capital letter implies that the accelerator is modified by the `Shift` key.
*
* Additional accelerators are `Meta`, `Control`, `Option`, each separated
* by commas. If one is not applicable for a platform, it will just be ignored.
*
* On MacOS `Meta` is the same as `Command`.
*
*
* Disabled Items
*
* If you want to disable a menu item just prefix the item with the `!` character.
* This will cause the item to appear disabled when the system menu renders.
*
*
* Submenus
*
* We feel like nested menus are an anti-pattern. We don't use them. If you have a
* strong argument for them and a very simple pull request that makes them work we
* may consider them.
*
*
* Event Handling
*
* When a menu item is activated, it raises the `menuItemSelected` event in
* the front end code, you can then communicate with your backend code if you
* want from there.
*
* For example, if the `Apple` item is selected from the `Other` menu...
*
* ```js
* window.addEventListener('menuItemSelected', event => {
* assert(event.detail.parent === 'Other')
* assert(event.detail.title === 'Apple')
* })
* ```
*
*/
export async function setSystemMenu (o) {
return await setMenu(o, 'system')
}
/**
* An alias to setSystemMenu for creating a tary menu
*/
export async function setTrayMenu (o) {
return await setMenu(o, 'tray')
}
/**
* Set the enabled state of the system menu.
* @param {object} value - an options object
* @return {Promise<ipc.Result>}
*/
export async function setSystemMenuItemEnabled (value) {
return await ipc.request('application.setSystemMenuItemEnabled', value)
}
/**
* Predicate function to determine if application is in a "paused" state.
* @return {boolean}
*/
export function isPaused () {
return isApplicationPaused
}
/**
* Socket Runtime version.
* @type {object} - an object containing the version information
*/
export const runtimeVersion = primordials.version
/**
* Runtime debug flag.
* @type {boolean}
*/
export const debug = !!globalThis.__args?.debug
/**
* Application configuration.
* @type {object}
*/
export const config = globalThis.__args?.config ?? {}
/**
* The application's backend instance.
*/
export const backend = {
/**
* @param {object} opts - an options object
* @param {boolean} [opts.force = false] - whether to force the existing process to close
* @return {Promise<ipc.Result>}
*/
async open (opts = {}) {
opts.force ??= false
return await ipc.send('process.open', opts)
},
/**
* @return {Promise<ipc.Result>}
*/
async close () {
return await ipc.send('process.kill')
}
}
export default exports