From 0b3deb8661c01b8eb27cd795dc6cf490586ab71f Mon Sep 17 00:00:00 2001 From: lars-berger Date: Tue, 2 Jul 2024 19:06:22 +0800 Subject: [PATCH] feat: run custom scripts on element events (#62) --- packages/client-api/src/index.ts | 1 + packages/client-api/src/init-element.ts | 21 +++ .../src/user-config/get-script-manager.ts | 92 +++++++++++++ packages/client-api/src/user-config/index.ts | 1 + .../window/base-element-config.model.ts | 2 + .../window/element-events-config.model.ts | 124 ++++++++++++++++++ .../src/user-config/window/index.ts | 1 + .../src/app/template-element.component.ts | 53 +++++++- packages/desktop/Cargo.lock | 7 + packages/desktop/Cargo.toml | 5 +- packages/desktop/capabilities/main.json | 2 + packages/desktop/resources/sample-config.yaml | 28 ++-- packages/desktop/resources/script.js | 3 + packages/desktop/tauri.conf.json | 6 +- 14 files changed, 329 insertions(+), 17 deletions(-) create mode 100644 packages/client-api/src/user-config/get-script-manager.ts create mode 100644 packages/client-api/src/user-config/window/element-events-config.model.ts create mode 100644 packages/desktop/resources/script.js diff --git a/packages/client-api/src/index.ts b/packages/client-api/src/index.ts index 450beeec..b111d0ca 100644 --- a/packages/client-api/src/index.ts +++ b/packages/client-api/src/index.ts @@ -1,5 +1,6 @@ export { createLogger, toCssSelector } from './utils'; export { + getScriptManager, getChildConfigs, type GlobalConfig, type WindowConfig, diff --git a/packages/client-api/src/init-element.ts b/packages/client-api/src/init-element.ts index 8d14f52e..a84bfceb 100644 --- a/packages/client-api/src/init-element.ts +++ b/packages/client-api/src/init-element.ts @@ -11,6 +11,7 @@ import { getParsedElementConfig, getChildConfigs, type GlobalConfig, + getScriptManager, } from './user-config'; import { getElementProviders } from './providers'; import type { ElementContext } from './element-context.model'; @@ -36,6 +37,7 @@ export async function initElement( ): Promise { try { const styleBuilder = getStyleBuilder(); + const scriptManager = getScriptManager(); const childConfigs = getChildConfigs(args.rawConfig); // Create partial element context; `providers` and `parsedConfig` are set later. @@ -71,6 +73,7 @@ export async function initElement( // provider. setElementContext('parsedConfig', parsedConfig); + // Build the CSS for the element. runWithOwner(args.owner, () => { createEffect(async () => { if (parsedConfig.styles) { @@ -89,6 +92,24 @@ export async function initElement( }); }); + // Preload the scripts used for the element's events. + runWithOwner(args.owner, () => { + createEffect(async () => { + try { + await Promise.all( + parsedConfig.events + .map(config => config.fn_path) + .map(scriptManager.loadScriptForFn), + ); + } catch (err) { + await showErrorDialog({ + title: `Non-fatal: Error in ${args.type}/${args.id}`, + error: err, + }); + } + }); + }); + async function initChildElement(id: string) { const childConfig = childConfigs.find( childConfig => childConfig.id === id, diff --git a/packages/client-api/src/user-config/get-script-manager.ts b/packages/client-api/src/user-config/get-script-manager.ts new file mode 100644 index 00000000..8d054363 --- /dev/null +++ b/packages/client-api/src/user-config/get-script-manager.ts @@ -0,0 +1,92 @@ +import { convertFileSrc } from '@tauri-apps/api/core'; +import { join, homeDir } from '@tauri-apps/api/path'; + +import { createLogger } from '~/utils'; +import type { ElementContext } from '../element-context.model'; + +const logger = createLogger('script-manager'); + +/** + * Map of module paths to asset paths. + */ +const assetPathCache: Record = {}; + +/** + * Map of asset paths to promises that resolve to the module. + */ +const moduleCache: Record> = {}; + +/** + * Abstraction over loading and invoking user-defined scripts. + */ +export function getScriptManager() { + return { + loadScriptForFn, + callFn, + }; +} + +async function loadScriptForFn(fnPath: string): Promise { + const { modulePath } = parseFnPath(fnPath); + return resolveModule(modulePath); +} + +async function callFn( + fnPath: string, + event: Event, + context: ElementContext, +): Promise { + const { modulePath, functionName } = parseFnPath(fnPath); + const foundModule = await resolveModule(modulePath); + const fn = foundModule[functionName]; + + if (!fn) { + throw new Error( + `No function with the name '${functionName}' at function path '${fnPath}'.`, + ); + } + + return fn(event, context); +} + +async function resolveModule(modulePath: string): Promise { + const assetPath = await getAssetPath(modulePath); + const foundModule = moduleCache[assetPath]; + + if (foundModule) { + return foundModule; + } + + logger.info(`Loading script at path '${assetPath}'.`); + return (moduleCache[assetPath] = import(assetPath)); +} + +/** + * Converts user-defined path to a URL that can be loaded by the webview. + */ +async function getAssetPath(modulePath: string): Promise { + const foundAssetPath = assetPathCache[modulePath]; + + if (foundAssetPath) { + return foundAssetPath; + } + + return (assetPathCache[modulePath] = convertFileSrc( + await join(await homeDir(), '.glzr/zebar', modulePath), + )); +} + +function parseFnPath(fnPath: string): { + modulePath: string; + functionName: string; +} { + const [modulePath, functionName] = fnPath.split('#'); + + // Should never been thrown, as the path is validated during config + // deserialization. + if (!modulePath || !functionName) { + throw new Error(`Invalid function path '${fnPath}'.`); + } + + return { modulePath, functionName }; +} diff --git a/packages/client-api/src/user-config/index.ts b/packages/client-api/src/user-config/index.ts index b7eb4e9e..51522251 100644 --- a/packages/client-api/src/user-config/index.ts +++ b/packages/client-api/src/user-config/index.ts @@ -1,4 +1,5 @@ export * from './get-parsed-element-config'; +export * from './get-script-manager'; export * from './get-style-builder'; export * from './get-user-config'; export * from './global-config.model'; diff --git a/packages/client-api/src/user-config/window/base-element-config.model.ts b/packages/client-api/src/user-config/window/base-element-config.model.ts index b40ad67f..f5ff4690 100644 --- a/packages/client-api/src/user-config/window/base-element-config.model.ts +++ b/packages/client-api/src/user-config/window/base-element-config.model.ts @@ -2,12 +2,14 @@ import { z } from 'zod'; import type { Prettify } from '~/utils'; import { ProvidersConfigSchema } from './providers-config.model'; +import { ElementEventsConfigSchema } from './element-events-config.model'; export const BaseElementConfigSchema = z.object({ id: z.string(), class_names: z.array(z.string()).default([]), styles: z.string().optional(), providers: ProvidersConfigSchema, + events: ElementEventsConfigSchema, }); /** Base config for windows, groups, and components. */ diff --git a/packages/client-api/src/user-config/window/element-events-config.model.ts b/packages/client-api/src/user-config/window/element-events-config.model.ts new file mode 100644 index 00000000..bcd6bef2 --- /dev/null +++ b/packages/client-api/src/user-config/window/element-events-config.model.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; +import type { Prettify } from '~/utils'; + +/** + * All available events on HTML elements. + **/ +const HTML_EVENTS = [ + 'click', + 'fullscreenchange', + 'fullscreenerror', + 'abort', + 'animationcancel', + 'animationend', + 'animationiteration', + 'animationstart', + 'auxclick', + 'beforeinput', + 'blur', + 'cancel', + 'canplay', + 'canplaythrough', + 'change', + 'close', + 'contextmenu', + 'copy', + 'cuechange', + 'cut', + 'dblclick', + 'drag', + 'dragend', + 'dragenter', + 'dragleave', + 'dragover', + 'dragstart', + 'drop', + 'durationchange', + 'emptied', + 'ended', + 'error', + 'focus', + 'formdata', + 'gotpointercapture', + 'input', + 'invalid', + 'keydown', + 'keypress', + 'keyup', + 'load', + 'loadeddata', + 'loadedmetadata', + 'loadstart', + 'lostpointercapture', + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseout', + 'mouseover', + 'mouseup', + 'paste', + 'pause', + 'play', + 'playing', + 'pointercancel', + 'pointerdown', + 'pointerenter', + 'pointerleave', + 'pointermove', + 'pointerout', + 'pointerover', + 'pointerup', + 'progress', + 'ratechange', + 'reset', + 'resize', + 'scroll', + 'scrollend', + 'securitypolicyviolation', + 'seeked', + 'seeking', + 'select', + 'selectionchange', + 'selectstart', + 'slotchange', + 'stalled', + 'submit', + 'suspend', + 'timeupdate', + 'toggle', + 'touchcancel', + 'touchend', + 'touchmove', + 'touchstart', + 'transitioncancel', + 'transitionend', + 'transitionrun', + 'transitionstart', + 'volumechange', + 'waiting', + 'webkitanimationend', + 'webkitanimationiteration', + 'webkitanimationstart', + 'webkittransitionend', + 'wheel', +] as const; + +export const ElementEventsConfigSchema = z + .array( + z.object({ + type: z.enum(HTML_EVENTS), + fn_path: z + .string() + .regex( + /^(.+)#([a-zA-Z_$][a-zA-Z0-9_$]*)$/, + "Invalid function path. Needs to be in format 'path/to/my-script.js#functionName'.", + ), + selector: z.string().optional(), + }), + ) + .default([]); + +export type ElementEventsConfig = Prettify< + z.infer +>; diff --git a/packages/client-api/src/user-config/window/index.ts b/packages/client-api/src/user-config/window/index.ts index a86bdddf..937e359a 100644 --- a/packages/client-api/src/user-config/window/index.ts +++ b/packages/client-api/src/user-config/window/index.ts @@ -1,4 +1,5 @@ export * from './base-element-config.model'; +export * from './element-events-config.model'; export * from './group-config.model'; export * from './provider-config.model'; export * from './provider-type.model'; diff --git a/packages/client/src/app/template-element.component.ts b/packages/client/src/app/template-element.component.ts index 0ed130a7..f192aeeb 100644 --- a/packages/client/src/app/template-element.component.ts +++ b/packages/client/src/app/template-element.component.ts @@ -1,20 +1,38 @@ import { createEffect, onCleanup, onMount } from 'solid-js'; -import { type ElementContext, createLogger, toCssSelector } from 'zebar'; +import { + type ElementContext, + createLogger, + toCssSelector, + getScriptManager, +} from 'zebar'; export interface TemplateElementProps { context: ElementContext; } +interface ElementEventListener { + eventType: string; + eventCallback: (event: Event) => Promise; + selectorElement: Element; +} + export function TemplateElement(props: TemplateElementProps) { const config = props.context.parsedConfig; const logger = createLogger(`#${config.id}`); + const scriptManager = getScriptManager(); // Create element with ID. const element = createRootElement(); + // Currently active event listeners. + let listeners: ElementEventListener[] = []; + // Update the HTML element when the template changes. - // @ts-ignore - TODO - createEffect(() => (element.innerHTML = config.template)); + createEffect(() => { + // @ts-ignore - TODO + element.innerHTML = config.template; + updateEventListeners(); + }); onMount(() => logger.debug('Mounted')); onCleanup(() => logger.debug('Cleanup')); @@ -26,5 +44,34 @@ export function TemplateElement(props: TemplateElementProps) { return element; } + function updateEventListeners() { + // Remove existing event listeners. + listeners.forEach(({ eventType, eventCallback, selectorElement }) => + selectorElement.removeEventListener(eventType, eventCallback), + ); + + listeners = []; + + config.events.forEach(eventConfig => { + const eventCallback = (event: Event) => + scriptManager.callFn(eventConfig.fn_path, event, props.context); + + // Default to the root element if no selector is provided. + const selectorElement = eventConfig.selector + ? element.querySelector(eventConfig.selector) + : element; + + if (selectorElement) { + selectorElement.addEventListener(eventConfig.type, eventCallback); + + listeners.push({ + eventType: eventConfig.type, + eventCallback, + selectorElement, + }); + } + }); + } + return element; } diff --git a/packages/desktop/Cargo.lock b/packages/desktop/Cargo.lock index 833dfc00..78a4298b 100644 --- a/packages/desktop/Cargo.lock +++ b/packages/desktop/Cargo.lock @@ -1944,6 +1944,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.9.4" @@ -4723,6 +4729,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http 1.1.0", + "http-range", "jni", "libc", "log", diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 648ae615..64e24ca2 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -17,7 +17,10 @@ anyhow = "1" async-trait = "0.1" clap = { version = "4", features = ["derive"] } reqwest = { version = "0.11", features = ["json"] } -tauri = { version = "2.0.0-beta", features = ["macos-private-api"] } +tauri = { version = "2.0.0-beta", features = [ + "protocol-asset", + "macos-private-api", +] } tauri-plugin-dialog = "2.0.0-beta" tauri-plugin-http = "2.0.0-beta" tauri-plugin-shell = "2.0.0-beta" diff --git a/packages/desktop/capabilities/main.json b/packages/desktop/capabilities/main.json index 53953338..447cb5d6 100644 --- a/packages/desktop/capabilities/main.json +++ b/packages/desktop/capabilities/main.json @@ -7,6 +7,8 @@ "app:default", "dialog:allow-message", "event:default", + "path:allow-resolve-directory", + "path:default", "window:default", "window:allow-center", "window:allow-close", diff --git a/packages/desktop/resources/sample-config.yaml b/packages/desktop/resources/sample-config.yaml index 6fb7d027..a28bdae4 100644 --- a/packages/desktop/resources/sample-config.yaml +++ b/packages/desktop/resources/sample-config.yaml @@ -18,7 +18,7 @@ window/bar: # Width of the window in physical pixels. width: '{{ self.args.MONITOR_WIDTH }}' # Height of the window in physical pixels. - height: '45' + height: '40' # X-position of the window in physical pixels. position_x: '{{ self.args.MONITOR_X }}' # Y-position of the window in physical pixels. @@ -35,7 +35,9 @@ window/bar: # for a cheatsheet of available Nerdfonts icons. global_styles: | @import "https://www.nerdfonts.com/assets/css/webfont.css"; - # CSS/SCSS styles to apply to the root element within the window. + # CSS/SCSS styles to apply to the root element within the window. Using + # CSS nesting, we can also target nested elements (e.g. below we set the + # color and margin-right of icons). styles: | display: grid; grid-template-columns: 1fr 1fr 1fr; @@ -43,13 +45,16 @@ window/bar: height: 100%; color: #ffffffe6; font-family: ui-monospace, monospace; - font-size: 13px; + font-size: 12px; padding: 4px 24px; border-bottom: 1px solid #ffffff08; - background: linear-gradient( - rgba(14, 14, 28, 0.95), - rgba(26, 14, 28, 0.85), - ); + background: linear-gradient(rgba(0, 0, 0, 0.9), rgba(4, 1, 18, 0.85)); + + i { + color: #7481b2e1; + margin-right: 7px; + } + group/left: styles: | display: flex; @@ -76,6 +81,10 @@ window/bar: border-radius: 2px; } providers: ['glazewm'] + events: + - type: 'click' + fn_path: 'script.js#invokeMe' + selector: '.workspace' template: | @for (workspace of glazewm.workspacesOnMonitor) { @@ -100,11 +109,6 @@ window/bar: margin-left: 20px; } - i { - color: #7481b2e1; - margin-right: 7px; - } - template/network: providers: ['network'] template: | diff --git a/packages/desktop/resources/script.js b/packages/desktop/resources/script.js new file mode 100644 index 00000000..1a2e6ee7 --- /dev/null +++ b/packages/desktop/resources/script.js @@ -0,0 +1,3 @@ +export function invokeMe(event, context) { + console.log('invokeMe', event, context); +} diff --git a/packages/desktop/tauri.conf.json b/packages/desktop/tauri.conf.json index 0e496a8f..11c3f476 100644 --- a/packages/desktop/tauri.conf.json +++ b/packages/desktop/tauri.conf.json @@ -34,7 +34,11 @@ "app": { "macOSPrivateApi": true, "security": { - "csp": null + "csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost", + "assetProtocol": { + "enable": true, + "scope": ["$HOME/.glzr/zebar/**"] + } } } }