diff --git a/.gitignore b/.gitignore index d3dd278b..94d9deec 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ gen .DS_Store *.pem *.env +examples/settings.json # Debug npm-debug.log* diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..6c2b9be4 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +link-workspace-packages=true +prefer-workspace-packages=true diff --git a/.vscode/launch.json b/.vscode/launch.json index 1a90431a..8a18be04 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,9 +11,7 @@ "--manifest-path=./packages/desktop/Cargo.toml", "--no-default-features" ] - }, - // Task for the `beforeDevCommand`, if used. - "preLaunchTask": "ui:dev" + } }, { "type": "lldb", @@ -25,9 +23,7 @@ "--release", "--manifest-path=./packages/desktop/Cargo.toml" ] - }, - // Task for the `beforeBuildCommand`, if used. - "preLaunchTask": "ui:build" + } } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index d664da34..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "ui:dev", - "type": "shell", - // `dev` keeps running in the background. - "isBackground": true, - "command": "pnpm", - "args": ["dev"], - "problemMatcher": { - "owner": "typescript", - "source": "ts", - "applyTo": "closedDocuments", - "fileLocation": ["relative", "${cwd}"], - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": "^.*", - "endsPattern": "^.*Compiled successfully.*" - } - }, - "options": { - "cwd": "${workspaceRoot}/packages/client" - } - }, - { - "label": "ui:build", - "type": "shell", - "command": "pnpm", - "args": ["build"], - "options": { - "cwd": "${workspaceRoot}/packages/client" - } - } - ] -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3ca2d84..828416fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,10 +31,9 @@ pnpm dev ## Architecture -Zebar is split into 3 packages: +Zebar is split into 2 packages: - `desktop` - a Tauri app which is a CLI that can spawn windows. -- `client` - a SolidJS frontend which is spawned by Tauri on `zebar open `. - `client-api` - business logic for communicating with Tauri. ### How to create a new provider? diff --git a/examples/boilerplates/react-buildless/index.html b/examples/boilerplates/react-buildless/index.html new file mode 100644 index 00000000..73ea16e8 --- /dev/null +++ b/examples/boilerplates/react-buildless/index.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + +
+ + + + diff --git a/examples/boilerplates/react-buildless/my-window.zebar.json b/examples/boilerplates/react-buildless/my-window.zebar.json new file mode 100644 index 00000000..cd2e9811 --- /dev/null +++ b/examples/boilerplates/react-buildless/my-window.zebar.json @@ -0,0 +1,23 @@ +{ + "$schema": "TODO", + "htmlPath": "./index.html", + "launchOptions": { + "zOrder": "normal", + "shownInTaskbar": false, + "focused": false, + "resizable": false, + "transparent": false, + "placements": [ + { + "anchor": "top_left", + "offsetX": "0px", + "offsetY": "0px", + "width": "100%", + "height": "40px", + "monitorSelection": { + "type": "all" + } + } + ] + } +} diff --git a/examples/boilerplates/react-buildless/styles.css b/examples/boilerplates/react-buildless/styles.css new file mode 100644 index 00000000..a81cadc7 --- /dev/null +++ b/examples/boilerplates/react-buildless/styles.css @@ -0,0 +1,23 @@ +body { + color: rgb(255 255 255 / 90%); + font-family: ui-monospace, monospace; + font-size: 12px; +} + +html, +body, +#root { + height: 100%; +} + +.app { + text-align: center; +} + +.chip { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + background: rgb(84, 66, 220); + margin-right: 4px; +} diff --git a/examples/boilerplates/solid-ts/.gitignore b/examples/boilerplates/solid-ts/.gitignore new file mode 100644 index 00000000..0ca39c00 --- /dev/null +++ b/examples/boilerplates/solid-ts/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.DS_Store diff --git a/examples/boilerplates/solid-ts/README.md b/examples/boilerplates/solid-ts/README.md new file mode 100644 index 00000000..a951337f --- /dev/null +++ b/examples/boilerplates/solid-ts/README.md @@ -0,0 +1,22 @@ +## Usage + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` + +Runs the app in the development mode. + +### `npm run build` + +Builds the app for production to the `dist` folder.
+It bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
diff --git a/examples/boilerplates/solid-ts/dist/index.html b/examples/boilerplates/solid-ts/dist/index.html new file mode 100644 index 00000000..5c6c9a50 --- /dev/null +++ b/examples/boilerplates/solid-ts/dist/index.html @@ -0,0 +1,15 @@ + + + + + + Zebar + + +

+ Boilerplate for SolidJS with TypeScript. Run npm i and + npm run dev in the solid-ts directory to + run this example. +

+ + diff --git a/packages/client/index.html b/examples/boilerplates/solid-ts/index.html similarity index 67% rename from packages/client/index.html rename to examples/boilerplates/solid-ts/index.html index ac00cdb3..f7fe1033 100644 --- a/packages/client/index.html +++ b/examples/boilerplates/solid-ts/index.html @@ -4,13 +4,12 @@ - Zebar + -
- - +
+ diff --git a/examples/boilerplates/solid-ts/my-window.zebar.json b/examples/boilerplates/solid-ts/my-window.zebar.json new file mode 100644 index 00000000..48bdd0f2 --- /dev/null +++ b/examples/boilerplates/solid-ts/my-window.zebar.json @@ -0,0 +1,23 @@ +{ + "$schema": "TODO", + "htmlPath": "./dist/index.html", + "launchOptions": { + "zOrder": "normal", + "shownInTaskbar": false, + "focused": false, + "resizable": false, + "transparent": false, + "placements": [ + { + "anchor": "top_left", + "offsetX": "0px", + "offsetY": "0px", + "width": "100%", + "height": "40px", + "monitorSelection": { + "type": "all" + } + } + ] + } +} diff --git a/examples/boilerplates/solid-ts/package.json b/examples/boilerplates/solid-ts/package.json new file mode 100644 index 00000000..c489af28 --- /dev/null +++ b/examples/boilerplates/solid-ts/package.json @@ -0,0 +1,18 @@ +{ + "name": "solidjs-ts", + "version": "0.0.0", + "description": "", + "scripts": { + "build": "vite build", + "dev": "vite build --watch" + }, + "dependencies": { + "solid-js": "1.8.11", + "zebar": "^2.0.0" + }, + "devDependencies": { + "typescript": "5.3.3", + "vite": "5.0.11", + "vite-plugin-solid": "2.8.2" + } +} diff --git a/examples/boilerplates/solid-ts/src/index.css b/examples/boilerplates/solid-ts/src/index.css new file mode 100644 index 00000000..a81cadc7 --- /dev/null +++ b/examples/boilerplates/solid-ts/src/index.css @@ -0,0 +1,23 @@ +body { + color: rgb(255 255 255 / 90%); + font-family: ui-monospace, monospace; + font-size: 12px; +} + +html, +body, +#root { + height: 100%; +} + +.app { + text-align: center; +} + +.chip { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + background: rgb(84, 66, 220); + margin-right: 4px; +} diff --git a/examples/boilerplates/solid-ts/src/index.tsx b/examples/boilerplates/solid-ts/src/index.tsx new file mode 100644 index 00000000..6da68176 --- /dev/null +++ b/examples/boilerplates/solid-ts/src/index.tsx @@ -0,0 +1,33 @@ +/* @refresh reload */ +import './index.css'; +import { render } from 'solid-js/web'; +import { createStore } from 'solid-js/store'; +import { init } from 'zebar'; + +const zebarCtx = await init(); + +const providers = await zebarCtx.createProviderGroup({ + cpu: { type: 'cpu' }, + battery: { type: 'battery' }, + memory: { type: 'memory' }, + weather: { type: 'weather' }, +}); + +render(() => , document.getElementById('root')!); + +function App() { + const [output, setOutput] = createStore(providers.outputMap); + + providers.onOutput(outputMap => setOutput(outputMap)); + + return ( +
+
CPU usage: {output.cpu.usage}
+
+ Battery charge: {output.battery?.chargePercent} +
+
Memory usage: {output.memory.usage}
+
Weather temp: {output.weather?.celsiusTemp}
+
+ ); +} diff --git a/examples/boilerplates/solid-ts/tsconfig.json b/examples/boilerplates/solid-ts/tsconfig.json new file mode 100644 index 00000000..5d2faf0a --- /dev/null +++ b/examples/boilerplates/solid-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true + } +} diff --git a/examples/boilerplates/solid-ts/vite.config.ts b/examples/boilerplates/solid-ts/vite.config.ts new file mode 100644 index 00000000..d3401ddc --- /dev/null +++ b/examples/boilerplates/solid-ts/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin()], + build: { target: 'esnext' }, + base: './', +}); diff --git a/examples/normalize.css b/examples/normalize.css new file mode 100644 index 00000000..2204e781 --- /dev/null +++ b/examples/normalize.css @@ -0,0 +1,204 @@ +/** + * Base CSS styles for better consistency across platforms. + * Yoinked from: https://github.com/sindresorhus/modern-normalize + */ + +/* +Document +======== +*/ + +/** +Use a better box model (opinionated). +*/ + +*, +::before, +::after { + box-sizing: border-box; +} + +html { + /* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */ + font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji'; + line-height: 1.15; /* 1. Correct the line height in all browsers. */ + -webkit-text-size-adjust: 100%; /* 2. Prevent adjustments of font size after orientation changes in iOS. */ + tab-size: 4; /* 3. Use a more readable tab size (opinionated). */ +} + +/* +Sections +======== +*/ + +body { + margin: 0; /* Remove the margin in all browsers. */ +} + +/* +Text-level semantics +==================== +*/ + +/** +Add the correct font weight in Chrome and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/** +1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) +2. Correct the odd 'em' font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', + Menlo, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/** +Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +Tabular data +============ +*/ + +/** +Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016) +*/ + +table { + border-color: currentcolor; +} + +/* +Forms +===== +*/ + +/** +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** +Correct the inability to style clickable types in iOS and Safari. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; +} + +/** +Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. +*/ + +legend { + padding: 0; +} + +/** +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/** +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/** +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to 'inherit' in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* +Interactive +=========== +*/ + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} diff --git a/examples/starter/styles.css b/examples/starter/styles.css new file mode 100644 index 00000000..a42fb93a --- /dev/null +++ b/examples/starter/styles.css @@ -0,0 +1,110 @@ +/** + * Import the Nerdfonts icon font. + * Ref https://www.nerdfonts.com/cheat-sheet for a cheatsheet of available Nerdfonts icons. + */ +@import 'https://www.nerdfonts.com/assets/css/webfont.css'; + +i { + color: rgb(115 130 175 / 95%); + margin-right: 7px; +} + +body { + color: rgb(255 255 255 / 90%); + font-family: ui-monospace, monospace; + font-size: 12px; +} + +html, +body, +#root { + height: 100%; +} + +#root { + border-bottom: 1px solid rgb(255 255 255 / 5%); + background: linear-gradient(rgb(0 0 0 / 90%), rgb(5 2 20 / 85%)); +} + +.app { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + height: 100%; + padding: 4px 1.5vw; +} + +.left, +.center, +.right { + display: flex; + align-items: center; +} + +.center { + justify-self: center; +} + +.right { + justify-self: end; +} + +.logo, +.binding-mode, +.tiling-direction, +.network, +.memory, +.cpu, +.battery { + margin-right: 20px; +} + +.workspaces { + display: flex; + align-items: center; +} + +.workspace { + background: rgb(255 255 255 / 5%); + margin-right: 4px; + padding: 4px 8px; + color: rgb(255 255 255 / 90%); + border: none; + border-radius: 2px; + cursor: pointer; + + &.displayed { + background: rgb(255 255 255 / 15%); + } + + &.focused, + &:hover { + background: rgb(75 115 255 / 50%); + } +} + +.binding-mode, +.tiling-direction { + background: rgb(255 255 255 / 15%); + color: rgb(255 255 255 / 90%); + border-radius: 2px; + line-height: 1; + padding: 4px 8px; + border: 0; + cursor: pointer; +} + +.binding-mode { + margin-right: 4px; +} + +.cpu .high-usage { + color: #900029; +} + +.battery .charging-icon { + position: absolute; + font-size: 11px; + left: 7px; + top: 2px; +} diff --git a/examples/starter/vanilla.html b/examples/starter/vanilla.html new file mode 100644 index 00000000..2f7da58e --- /dev/null +++ b/examples/starter/vanilla.html @@ -0,0 +1,177 @@ + + + + + + + + + + + + + +
+ + + + diff --git a/examples/starter/vanilla.zebar.json b/examples/starter/vanilla.zebar.json new file mode 100644 index 00000000..390b8f67 --- /dev/null +++ b/examples/starter/vanilla.zebar.json @@ -0,0 +1,23 @@ +{ + "$schema": "TODO", + "htmlPath": "./vanilla.html", + "launchOptions": { + "zOrder": "normal", + "shownInTaskbar": false, + "focused": false, + "resizable": false, + "transparent": false, + "placements": [ + { + "anchor": "top_left", + "offsetX": "0px", + "offsetY": "0px", + "width": "100%", + "height": "40px", + "monitorSelection": { + "type": "all" + } + } + ] + } +} diff --git a/examples/starter/with-glazewm.html b/examples/starter/with-glazewm.html new file mode 100644 index 00000000..7ecb355a --- /dev/null +++ b/examples/starter/with-glazewm.html @@ -0,0 +1,215 @@ + + + + + + + + + + + + + +
+ + + + diff --git a/examples/starter/with-glazewm.zebar.json b/examples/starter/with-glazewm.zebar.json new file mode 100644 index 00000000..465424d7 --- /dev/null +++ b/examples/starter/with-glazewm.zebar.json @@ -0,0 +1,23 @@ +{ + "$schema": "TODO", + "htmlPath": "./with-glazewm.html", + "launchOptions": { + "zOrder": "normal", + "shownInTaskbar": false, + "focused": false, + "resizable": false, + "transparent": false, + "placements": [ + { + "anchor": "top_left", + "offsetX": "0px", + "offsetY": "0px", + "width": "100%", + "height": "40px", + "monitorSelection": { + "type": "all" + } + } + ] + } +} diff --git a/examples/starter/with-komorebi.html b/examples/starter/with-komorebi.html new file mode 100644 index 00000000..adce2d05 --- /dev/null +++ b/examples/starter/with-komorebi.html @@ -0,0 +1,191 @@ + + + + + + + + + + + + + +
+ + + + diff --git a/examples/starter/with-komorebi.zebar.json b/examples/starter/with-komorebi.zebar.json new file mode 100644 index 00000000..92316f36 --- /dev/null +++ b/examples/starter/with-komorebi.zebar.json @@ -0,0 +1,23 @@ +{ + "$schema": "TODO", + "htmlPath": "./with-komorebi.html", + "launchOptions": { + "zOrder": "normal", + "shownInTaskbar": false, + "focused": false, + "resizable": false, + "transparent": false, + "placements": [ + { + "anchor": "top_left", + "offsetX": "0px", + "offsetY": "0px", + "width": "100%", + "height": "40px", + "monitorSelection": { + "type": "all" + } + } + ] + } +} diff --git a/package.json b/package.json index 30cee86e..cf214979 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ ], "scripts": { "build": "pnpm run -r build", - "dev": "pnpm run --parallel dev", + "dev": "pnpm run --filter zebar build && pnpm run --parallel dev", "format": "prettier --write . && pnpm run -r format", "lint": "prettier --check . && pnpm run -r lint" }, diff --git a/packages/client-api/package.json b/packages/client-api/package.json index 8638e338..7efbe07f 100644 --- a/packages/client-api/package.json +++ b/packages/client-api/package.json @@ -1,20 +1,26 @@ { "name": "zebar", - "version": "0.0.0", + "version": "2.0.0", + "description": "Client API for Zebar - a tool for creating customizable taskbars, desktop widgets, and popups.", + "repository": "github:glzr-io/zebar", + "license": "GPL-3.0-only", + "author": "Lars Berger", + "sideEffects": false, "type": "module", "exports": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" } }, - "main": "./dist/index.js", "module": "./dist/index.js", - "browser": {}, "types": "./dist/index.d.ts", - "typesVersions": {}, + "files": [ + "dist", + "README.md" + ], "scripts": { - "build": "tsup", + "build": "tsup src/index.ts --format esm --dts", "dev": "npm run build -- --watch src", "prepublishOnly": "npm run build" }, @@ -22,17 +28,14 @@ "@tauri-apps/api": "2.0.0-beta.15", "@tauri-apps/plugin-dialog": "2.0.0-beta.7", "@tauri-apps/plugin-shell": "2.0.0-beta.8", - "glazewm": "1.4.1", + "glazewm": "1.4.3", "luxon": "3.4.4", - "solid-js": "1.8.14", - "yaml": "2.3.4", "zod": "3.22.4" }, "devDependencies": { "@types/luxon": "3.4.2", "esbuild": "0.20.0", "tsup": "8.0.2", - "tsup-preset-solid": "2.2.0", "typescript": "5.3.3" } } diff --git a/packages/client-api/src/desktop/current-window.ts b/packages/client-api/src/desktop/current-window.ts index c2fe39a2..ed35922f 100644 --- a/packages/client-api/src/desktop/current-window.ts +++ b/packages/client-api/src/desktop/current-window.ts @@ -5,7 +5,7 @@ import { type Window, } from '@tauri-apps/api/window'; -import type { ZOrder } from '~/user-config'; +import { WindowZOrder } from '~/user-config'; import { createLogger } from '~/utils'; import { setAlwaysOnTop, setSkipTaskbar } from './desktop-commands'; @@ -17,7 +17,7 @@ export interface WindowPosition { } export interface WindowStyles { - zOrder: ZOrder; + zOrder: WindowZOrder; shownInTaskbar: boolean; resizable: boolean; } @@ -66,7 +66,10 @@ export async function setWindowStyles(styles: Partial) { ]); } -async function setWindowZOrder(window: Window, zOrder?: ZOrder) { +export async function setWindowZOrder( + window: Window, + zOrder?: WindowZOrder, +) { if (zOrder === 'always_on_bottom') { await window.setAlwaysOnBottom(true); } else if (zOrder === 'always_on_top') { diff --git a/packages/client-api/src/desktop/desktop-commands.ts b/packages/client-api/src/desktop/desktop-commands.ts index 4b21da9b..1a185440 100644 --- a/packages/client-api/src/desktop/desktop-commands.ts +++ b/packages/client-api/src/desktop/desktop-commands.ts @@ -4,34 +4,24 @@ import { } from '@tauri-apps/api/core'; import { createLogger } from '../utils'; -import type { ProviderConfig } from '~/user-config'; -import type { OpenWindowArgs } from './shared'; +import type { WindowState } from './shared'; +import type { ProviderConfig } from '~/providers'; const logger = createLogger('desktop-commands'); /** - * Reads config file from disk. Creates file if it doesn't exist. + * Get state associated with the given {@link windowId}. */ -export function readConfigFile(): Promise { - return invoke('read_config_file'); -} - -/** - * Get args used to open the window with the {@link windowLabel}. - */ -export function getOpenWindowArgs( - windowLabel: string, -): Promise { - return invoke('get_open_window_args', { - windowLabel, +export function getWindowState( + windowId: string, +): Promise { + return invoke('get_window_state', { + windowId, }); } -export function openWindow( - windowId: string, - args: Record = {}, -): Promise { - return invoke('open_window', { windowId, args }); +export function openWindow(configPath: string): Promise { + return invoke('open_window', { configPath }); } // TODO: Add support for only fetching selected variables. @@ -76,6 +66,7 @@ export async function invoke( return response; } catch (err) { + logger.error(`Command '${command}' failed: ${err}`); throw new Error(`Command '${command}' failed: ${err}`); } } diff --git a/packages/client-api/src/desktop/desktop-events.ts b/packages/client-api/src/desktop/desktop-events.ts index 28c16169..5e3f90dd 100644 --- a/packages/client-api/src/desktop/desktop-events.ts +++ b/packages/client-api/src/desktop/desktop-events.ts @@ -3,16 +3,13 @@ import { type Event, type UnlistenFn, } from '@tauri-apps/api/event'; +import type { ProviderConfig } from '~/providers'; -import { createLogger } from '~/utils'; +import { createLogger, simpleHash } from '~/utils'; +import { listenProvider, unlistenProvider } from './desktop-commands'; const logger = createLogger('desktop-events'); -export interface ProviderEmitEvent { - configHash: string; - variables: { data: T } | { error: string }; -} - let listenPromise: Promise | null = null; let callbacks: { @@ -20,26 +17,42 @@ let callbacks: { fn: (payload: Event>) => void; }[] = []; +export interface ProviderEmitEvent { + configHash: string; + result: { output: T } | { error: string }; +} + /** * Listen for provider data. */ export async function onProviderEmit( - configHash: string, - callback: (payload: T) => void, -): Promise { + config: ProviderConfig, + callback: (event: ProviderEmitEvent) => void, +): Promise<() => Promise> { + const configHash = simpleHash(config); + registerEventCallback(configHash, callback); const unlisten = await (listenPromise ?? (listenPromise = listenProviderEmit())); - // Unlisten when there are no active callbacks. - return () => { + await listenProvider({ + configHash, + config, + trackedAccess: [], + }); + + return async () => { callbacks = callbacks.filter( callback => callback.configHash !== configHash, ); + await unlistenProvider(configHash); + + // Unlisten when there are no active callbacks. if (callbacks.length === 0) { unlisten(); + listenPromise = null; } }; } @@ -49,7 +62,7 @@ export async function onProviderEmit( */ function registerEventCallback( configHash: string, - callback: (payload: T) => void, + callback: (event: ProviderEmitEvent) => void, ) { const wrappedCallback = (event: Event>) => { // Ignore provider emissions for different configs. @@ -57,15 +70,8 @@ function registerEventCallback( return; } - const { variables } = event.payload; - - if ('error' in variables) { - logger.error('Incoming provider error:', variables.error); - throw new Error(variables.error); - } - - logger.debug('Incoming provider variables:', variables.data); - callback(variables.data as T); + logger.debug('Incoming provider emission:', event.payload); + callback(event.payload); }; callbacks.push({ configHash, fn: wrappedCallback }); diff --git a/packages/client-api/src/desktop/get-open-window-args.ts b/packages/client-api/src/desktop/get-open-window-args.ts index 50945928..d2c91f26 100644 --- a/packages/client-api/src/desktop/get-open-window-args.ts +++ b/packages/client-api/src/desktop/get-open-window-args.ts @@ -1,12 +1,6 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; -import { createStore } from 'solid-js/store'; -import type { OpenWindowArgs } from './shared'; -import { getOpenWindowArgs } from './desktop-commands'; - -const [openWindowArgs, setOpenWindowArgs] = createStore({ - value: null as OpenWindowArgs | null, -}); +import { getWindowState } from './desktop-commands'; let promise: Promise | null = null; @@ -15,9 +9,9 @@ export async function _getOpenWindowArgs() { } async function fetchOpenWindowArgs() { - if (window.__ZEBAR_OPEN_ARGS) { - return window.__ZEBAR_OPEN_ARGS; + if (window.__ZEBAR_INITIAL_STATE) { + return window.__ZEBAR_INITIAL_STATE; } - return getOpenWindowArgs(await getCurrentWindow().label); + return getWindowState(getCurrentWindow().label); } diff --git a/packages/client-api/src/desktop/monitors.ts b/packages/client-api/src/desktop/monitors.ts index e6142fab..db74f40b 100644 --- a/packages/client-api/src/desktop/monitors.ts +++ b/packages/client-api/src/desktop/monitors.ts @@ -5,7 +5,6 @@ import { primaryMonitor as getPrimaryMonitor, getCurrentWindow, } from '@tauri-apps/api/window'; -import { createStore } from 'solid-js/store'; import type { MonitorInfo } from './shared'; @@ -37,12 +36,12 @@ async function createMonitorCache() { // return value, and refresh it in an effect when displays are changed. // Ref https://github.com/tauri-apps/tauri/issues/8405 - const [monitorCache, setMonitorCache] = createStore({ + const monitorCache = { currentMonitor: currentMonitor ? toMonitorInfo(currentMonitor) : null, primaryMonitor: primaryMonitor ? toMonitorInfo(primaryMonitor) : null, secondaryMonitors: secondaryMonitors.map(toMonitorInfo), allMonitors: allMonitors.map(toMonitorInfo), - }); + }; getCurrentWindow().onResized(() => updateCurrentMonitor()); getCurrentWindow().onMoved(() => updateCurrentMonitor()); @@ -51,7 +50,8 @@ async function createMonitorCache() { async function updateCurrentMonitor() { const currentMonitor = await getCurrentMonitor(); - setMonitorCache({ + // TODO: Avoid mutating the cache object. + Object.assign(monitorCache, { currentMonitor: currentMonitor ? toMonitorInfo(currentMonitor) : null, diff --git a/packages/client-api/src/desktop/shared/index.ts b/packages/client-api/src/desktop/shared/index.ts index dcea8b96..07e227a5 100644 --- a/packages/client-api/src/desktop/shared/index.ts +++ b/packages/client-api/src/desktop/shared/index.ts @@ -1,3 +1,2 @@ export * from './monitor-info.model'; -export * from './open-window-args.model'; -export * from './window-info.model'; +export * from './window-state.model'; diff --git a/packages/client-api/src/desktop/shared/open-window-args.model.ts b/packages/client-api/src/desktop/shared/open-window-args.model.ts deleted file mode 100644 index 04c76e46..00000000 --- a/packages/client-api/src/desktop/shared/open-window-args.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface OpenWindowArgs { - args: Record; - env: Record; - windowId: string; -} diff --git a/packages/client-api/src/desktop/shared/window-info.model.ts b/packages/client-api/src/desktop/shared/window-info.model.ts deleted file mode 100644 index 6d1e0e11..00000000 --- a/packages/client-api/src/desktop/shared/window-info.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface WindowInfo { - x: number; - y: number; - width: number; - height: number; - scaleFactor: number; -} diff --git a/packages/client-api/src/desktop/shared/window-state.model.ts b/packages/client-api/src/desktop/shared/window-state.model.ts new file mode 100644 index 00000000..f348c881 --- /dev/null +++ b/packages/client-api/src/desktop/shared/window-state.model.ts @@ -0,0 +1,9 @@ +import type { WindowConfig } from '~/user-config'; + +export interface WindowState { + windowId: string; + + config: WindowConfig; + + configPath: string; +} diff --git a/packages/client-api/src/element-context.model.ts b/packages/client-api/src/element-context.model.ts deleted file mode 100644 index ba4f79bc..00000000 --- a/packages/client-api/src/element-context.model.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { - GlobalConfig, - GroupConfig, - TemplateConfig, - WindowConfig, -} from '~/user-config'; -import { ElementType } from './element-type.model'; - -export type ElementConfig = WindowConfig | GroupConfig | TemplateConfig; - -interface BaseElementContext { - /** - * ID of this element. - */ - id: string; - - /** - * Type of this element. - */ - type: ElementType; - - /** - * Unparsed config for this element. - */ - rawConfig: unknown; - - /** - * Parsed config for this element. - */ - parsedConfig: C; - - /** - * Global user config. - */ - globalConfig: GlobalConfig; - - /** - * Args used to open the window. - */ - args: Record; - - /** - * Environment variables when window was opened. - */ - env: Record; - - /** - * Map of this element's providers and their variables. - */ - providers: P; - - /** - * Opens a new window by its ID. - */ - openWindow: ( - windowId: string, - args?: Record, - ) => Promise; - - /** - * Initializes a child group or template element. - * @internal - */ - initChildElement: ( - id: string, - ) => Promise; -} - -export type WindowContext

= BaseElementContext; -export type GroupContext

= BaseElementContext; -export type TemplateContext

= BaseElementContext< - TemplateConfig, - P ->; - -export type ElementContext = - | WindowContext - | GroupContext - | TemplateContext; diff --git a/packages/client-api/src/element-type.model.ts b/packages/client-api/src/element-type.model.ts deleted file mode 100644 index 781bd35c..00000000 --- a/packages/client-api/src/element-type.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum ElementType { - WINDOW = 'window', - GROUP = 'group', - TEMPLATE = 'template', -} diff --git a/packages/client-api/src/index.ts b/packages/client-api/src/index.ts index b111d0ca..8eca8acb 100644 --- a/packages/client-api/src/index.ts +++ b/packages/client-api/src/index.ts @@ -1,20 +1,3 @@ -export { createLogger, toCssSelector } from './utils'; -export { - getScriptManager, - getChildConfigs, - type GlobalConfig, - type WindowConfig, - type GroupConfig, - type TemplateConfig, - type ProviderConfig, - type ProvidersConfig, -} from './user-config'; -export { - type ElementContext, - type ElementConfig, - type WindowContext, - type GroupContext, - type TemplateContext, -} from './element-context.model'; -export { ElementType } from './element-type.model'; -export { initWindow, initWindowAsync } from './init-window'; +export * from './user-config'; +export * from './zebar-context.model'; +export * from './init'; diff --git a/packages/client-api/src/init-element.ts b/packages/client-api/src/init-element.ts deleted file mode 100644 index f2fce8bb..00000000 --- a/packages/client-api/src/init-element.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - type Accessor, - type Owner, - createEffect, - runWithOwner, -} from 'solid-js'; -import { createStore } from 'solid-js/store'; - -import { - getStyleBuilder, - getParsedElementConfig, - getChildConfigs, - type GlobalConfig, - getScriptManager, -} from './user-config'; -import { getElementProviders } from './providers'; -import type { ElementContext } from './element-context.model'; -import { ElementType } from './element-type.model'; -import { createLogger, type PickPartial } from './utils'; -import { openWindow, showErrorDialog } from './desktop'; - -const logger = createLogger('init-element'); - -export interface InitElementArgs { - id: string; - type: ElementType; - rawConfig: unknown; - globalConfig: GlobalConfig; - args: Record; - env: Record; - ancestorProviders: Accessor>[]; - owner: Owner; -} - -export async function initElement( - args: InitElementArgs, -): Promise { - try { - const styleBuilder = getStyleBuilder(); - const scriptManager = getScriptManager(); - const childConfigs = getChildConfigs(args.rawConfig); - - // Create partial element context; `providers` and `parsedConfig` are set later. - const [elementContext, setElementContext] = createStore< - PickPartial - >({ - id: args.id, - type: args.type, - rawConfig: args.rawConfig, - globalConfig: args.globalConfig, - args: args.args, - env: args.env, - openWindow, - initChildElement, - providers: undefined, - parsedConfig: undefined, - }); - - const { element, merged } = await getElementProviders( - elementContext, - args.ancestorProviders, - args.owner, - ); - - setElementContext('providers', merged); - - const parsedConfig = getParsedElementConfig( - elementContext as PickPartial, - args.owner, - ); - - // Since `parsedConfig` and `providers` are set after initializing providers - // and parsing the element config, they are initially unavailable on 'self' - // provider. - setElementContext('parsedConfig', parsedConfig); - - // Build the CSS for the element. - runWithOwner(args.owner, () => { - createEffect(async () => { - if (parsedConfig.styles) { - try { - styleBuilder.buildElementStyles( - parsedConfig.id, - parsedConfig.styles, - ); - } catch (err) { - await showErrorDialog({ - title: `Non-fatal: Error in ${args.type}/${args.id}`, - error: err, - }); - } - } - }); - }); - - // 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, - ); - - // Check whether an element with the given ID exists in the config. - if (!childConfig) { - return null; - } - - return initElement({ - id, - type: childConfig.type, - rawConfig: childConfig.config, - globalConfig: args.globalConfig, - args: args.args, - env: args.env, - ancestorProviders: [...(args.ancestorProviders ?? []), element], - owner: args.owner, - }); - } - - return elementContext as ElementContext; - } catch (err) { - // Let error immediately bubble up if element is a window. - if (args.type !== ElementType.WINDOW) { - logger.error('Failed to initialize element:', err); - - await showErrorDialog({ - title: `Non-fatal: Error in ${args.type}/${args.id}`, - error: err, - }); - } - - throw err; - } -} diff --git a/packages/client-api/src/init-window.ts b/packages/client-api/src/init-window.ts deleted file mode 100644 index 1aa33ddc..00000000 --- a/packages/client-api/src/init-window.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { createEffect, getOwner, runWithOwner } from 'solid-js'; - -import { - GlobalConfigSchema, - type UserConfig, - getUserConfig, - getStyleBuilder, - parseWithSchema, -} from './user-config'; -import { - getOpenWindowArgs, - setWindowPosition, - setWindowStyles, - showErrorDialog, - type WindowPosition, - type WindowStyles, -} from './desktop'; -import { initElement } from './init-element'; -import type { WindowContext } from './element-context.model'; -import { ElementType } from './element-type.model'; -import { createLogger } from '~/utils'; - -const logger = createLogger('init-window'); - -export function initWindow(callback: (context: WindowContext) => void) { - initWindowAsync().then(callback); -} - -/** - * Handles initialization. - * - * Steps involved: - * * Reading the user config - * * Creation of the window context - * * Positioning the window - * * Building CSS and appending it to `` - */ -export async function initWindowAsync(): Promise { - // Window ID is moved out of the try-catch to improve error messages. - let windowId: string | null = null; - - try { - // TODO: Create new root if owner is null. - const owner = getOwner()!; - const config = await getUserConfig(); - const styleBuilder = getStyleBuilder(); - - const openArgs = - window.__ZEBAR_OPEN_ARGS ?? - (await getOpenWindowArgs(getCurrentWindow().label)); - - windowId = openArgs.windowId; - const windowConfig = (config as UserConfig)[ - `window/${windowId}` as const - ]; - - if (!windowConfig) { - throw new Error( - `Window \`${windowId}\` isn\'t defined in the config. ` + - `Is there a property for \`window/${windowId}\`?`, - ); - } - - const globalConfig = parseWithSchema( - GlobalConfigSchema.strip(), - (config as UserConfig)?.global ?? {}, - ); - - const windowContext = (await initElement({ - id: windowId, - type: ElementType.WINDOW, - rawConfig: windowConfig, - globalConfig, - args: openArgs.args, - env: openArgs.env, - ancestorProviders: [], - owner, - })) as WindowContext; - - // Set global CSS styles. - runWithOwner(owner, () => { - createEffect(async () => { - if (windowContext.parsedConfig.global_styles) { - try { - styleBuilder.buildGlobalStyles( - windowContext.parsedConfig.global_styles, - ); - } catch (err) { - await showErrorDialog({ - title: `Non-fatal: Error in window/${windowId}`, - error: err, - }); - } - } - }); - }); - - // Set window position and apply window styles/effects. - runWithOwner(owner, () => { - createEffect(async () => { - // Create `styles` and `position` variables prior to awaiting, such that - // dependencies are tracked successfully within the effect. - const styles: Partial = { - zOrder: windowContext.parsedConfig.z_order, - shownInTaskbar: - windowContext.parsedConfig.show_in_taskbar || - windowContext.parsedConfig.shown_in_taskbar, - resizable: windowContext.parsedConfig.resizable, - }; - - const position: Partial = { - x: windowContext.parsedConfig.position_x, - y: windowContext.parsedConfig.position_y, - width: windowContext.parsedConfig.width, - height: windowContext.parsedConfig.height, - }; - - await setWindowStyles(styles); - await setWindowPosition(position); - }); - }); - - return windowContext; - } catch (err) { - logger.error('Failed to initialize window:', err); - - await showErrorDialog({ - title: windowId - ? `Fatal: Error in window/${windowId}` - : 'Fatal: Error in unknown window', - error: err, - }); - - // Error during window initialization is unrecoverable, so we close - // the window. - await getCurrentWindow().close(); - - throw err; - } -} diff --git a/packages/client-api/src/init.ts b/packages/client-api/src/init.ts new file mode 100644 index 00000000..23336ff5 --- /dev/null +++ b/packages/client-api/src/init.ts @@ -0,0 +1,69 @@ +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { join } from '@tauri-apps/api/path'; + +import { + getWindowState, + openWindow, + setWindowZOrder, + showErrorDialog, +} from '~/desktop'; +import { createLogger } from '~/utils'; +import { createProvider, createProviderGroup } from '~/providers'; +import type { ZebarContext } from './zebar-context.model'; + +const logger = createLogger('init-window'); + +/** + * Handles initialization. + */ +export async function init(): Promise { + try { + const currentWindow = getCurrentWindow(); + + const windowState = + window.__ZEBAR_INITIAL_STATE ?? + (await getWindowState(currentWindow.label)); + + // @ts-ignore - TODO + return { + openWindow: async (configPath: string) => { + // Ensure the config path ends with '.zebar.json'. + const filePath = configPath.endsWith('.zebar.json') + ? configPath + : `${configPath}.zebar.json`; + + const absolutePath = await join( + windowState.configPath, + '../', + filePath, + ); + + return openWindow(absolutePath); + }, + createProvider, + createProviderGroup, + currentWindow: { + ...windowState, + tauri: currentWindow, + setZOrder: zOrder => { + return setWindowZOrder(currentWindow, zOrder); + }, + }, + allWindows: [], + currentMonitor: {}, + allMonitors: [], + } as ZebarContext; + } catch (err) { + logger.error('Failed to initialize window:', err); + + await showErrorDialog({ + title: 'Failed to initialize window', + error: err, + }); + + // Error during window initialization is unrecoverable, so we close + // the window. + getCurrentWindow().close(); + throw err; + } +} diff --git a/packages/client-api/src/providers/battery/create-battery-provider.ts b/packages/client-api/src/providers/battery/create-battery-provider.ts index 2093e0d7..d1b43807 100644 --- a/packages/client-api/src/providers/battery/create-battery-provider.ts +++ b/packages/client-api/src/providers/battery/create-battery-provider.ts @@ -1,9 +1,31 @@ -import type { Owner } from 'solid-js'; +import { z } from 'zod'; -import type { BatteryProviderConfig } from '~/user-config'; -import { createProviderListener } from '../create-provider-listener'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; -export interface BatteryVariables { +export interface BatteryProviderConfig { + type: 'battery'; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; +} + +const batteryProviderConfigSchema = z.object({ + type: z.literal('battery'), + refreshInterval: z.coerce.number().default(60 * 1000), +}); + +export type BatteryProvider = Provider< + BatteryProviderConfig, + BatteryOutput +>; + +export interface BatteryOutput { chargePercent: number; cycleCount: number; healthPercent: number; @@ -17,40 +39,16 @@ export interface BatteryVariables { export async function createBatteryProvider( config: BatteryProviderConfig, - owner: Owner, -) { - const batteryVariables = await createProviderListener< - BatteryProviderConfig, - BatteryVariables - >(config, owner); - - return { - get chargePercent() { - return batteryVariables().chargePercent; - }, - get cycleCount() { - return batteryVariables().cycleCount; - }, - get healthPercent() { - return batteryVariables().healthPercent; - }, - get powerConsumption() { - return batteryVariables().powerConsumption; - }, - get state() { - return batteryVariables().state; - }, - get isCharging() { - return batteryVariables().isCharging; - }, - get timeTillEmpty() { - return batteryVariables().timeTillEmpty; - }, - get timeTillFull() { - return batteryVariables().timeTillFull; - }, - get voltage() { - return batteryVariables().voltage; - }, - }; +): Promise { + const mergedConfig = batteryProviderConfigSchema.parse(config); + + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); } diff --git a/packages/client-api/src/providers/cpu/create-cpu-provider.ts b/packages/client-api/src/providers/cpu/create-cpu-provider.ts index 32152017..9716e2bb 100644 --- a/packages/client-api/src/providers/cpu/create-cpu-provider.ts +++ b/packages/client-api/src/providers/cpu/create-cpu-provider.ts @@ -1,9 +1,28 @@ -import type { Owner } from 'solid-js'; +import { z } from 'zod'; -import type { CpuProviderConfig } from '~/user-config'; -import { createProviderListener } from '../create-provider-listener'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; -export interface CpuVariables { +export interface CpuProviderConfig { + type: 'cpu'; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; +} + +const cpuProviderConfigSchema = z.object({ + type: z.literal('cpu'), + refreshInterval: z.coerce.number().default(5 * 1000), +}); + +export type CpuProvider = Provider; + +export interface CpuOutput { frequency: number; usage: number; logicalCoreCount: number; @@ -13,28 +32,16 @@ export interface CpuVariables { export async function createCpuProvider( config: CpuProviderConfig, - owner: Owner, -) { - const cpuVariables = await createProviderListener< - CpuProviderConfig, - CpuVariables - >(config, owner); - - return { - get frequency() { - return cpuVariables().frequency; - }, - get usage() { - return cpuVariables().usage; - }, - get logicalCoreCount() { - return cpuVariables().logicalCoreCount; - }, - get physicalCoreCount() { - return cpuVariables().physicalCoreCount; - }, - get vendor() { - return cpuVariables().vendor; - }, - }; +): Promise { + const mergedConfig = cpuProviderConfigSchema.parse(config); + + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); } diff --git a/packages/client-api/src/providers/create-base-provider.ts b/packages/client-api/src/providers/create-base-provider.ts new file mode 100644 index 00000000..59a2a3af --- /dev/null +++ b/packages/client-api/src/providers/create-base-provider.ts @@ -0,0 +1,137 @@ +import { Deferred } from '~/utils'; +import type { ProviderConfig } from './create-provider'; + +export interface Provider { + /** + * Latest output emitted from the provider. + * + * `null` if the latest emission from the provider is an error. + */ + output: TOutput | null; + + /** + * Latest error message emitted from the provider. + * + * `null` if the latest emission from the provider is a valid output. + */ + error: string | null; + + /** + * Whether the latest emission from the provider is an error. + */ + hasError: boolean; + + /** + * Underlying config for the provider. + */ + config: TConfig; + + /** + * Restarts the provider. + */ + restart(): Promise; + + /** + * Stops the provider. + */ + stop(): Promise; + + /** + * Listens for outputs from the provider. + * + * @param callback - Callback to run when an output is emitted. + */ + onOutput(callback: (output: TOutput) => void): void; + + /** + * Listens for errors from the provider. + * + * @param callback - Callback to run when an error is emitted. + */ + onError(callback: (error: string) => void): void; +} + +type UnlistenFn = () => void | Promise; + +/** + * Fetches next output or error from the provider. + */ +type ProviderFetcher = (queue: { + output: (nextOutput: T) => void; + error: (nextError: string) => void; +}) => UnlistenFn | Promise; + +export async function createBaseProvider< + TConfig extends ProviderConfig, + TOutput, +>( + config: TConfig, + fetcher: ProviderFetcher, +): Promise> { + const outputListeners = new Set<(output: TOutput) => void>(); + const errorListeners = new Set<(error: string) => void>(); + + let latestEmission = { + output: null as TOutput | null, + error: null as string | null, + hasError: false, + }; + + let unlisten: UnlistenFn | null = await startFetcher(); + + async function startFetcher() { + const hasFirstEmit = new Deferred(); + + const unlisten = await fetcher({ + output: output => { + latestEmission = { output, error: null, hasError: false }; + outputListeners.forEach(listener => listener(output)); + hasFirstEmit.resolve(); + }, + error: error => { + latestEmission = { output: null, error, hasError: true }; + errorListeners.forEach(listener => listener(error)); + hasFirstEmit.resolve(); + }, + }); + + // Wait for the first emission. + await hasFirstEmit.promise; + + return unlisten; + } + + return { + get output() { + return latestEmission.output; + }, + get error() { + return latestEmission.error; + }, + get hasError() { + return latestEmission.hasError; + }, + config, + restart: async () => { + if (unlisten) { + await unlisten(); + } + + await startFetcher(); + }, + stop: async () => { + outputListeners.clear(); + errorListeners.clear(); + + if (unlisten) { + await unlisten(); + } + }, + onOutput: callback => { + outputListeners.add(callback); + }, + onError: callback => { + errorListeners.add(callback); + }, + }; +} diff --git a/packages/client-api/src/providers/create-provider-group.ts b/packages/client-api/src/providers/create-provider-group.ts new file mode 100644 index 00000000..ba9bfe98 --- /dev/null +++ b/packages/client-api/src/providers/create-provider-group.ts @@ -0,0 +1,160 @@ +import type { ZebarContext } from '~/zebar-context.model'; +import { + createProvider, + type ProviderConfig, + type ProviderMap, +} from './create-provider'; + +/** + * Config for creating multiple provider instances at once. + * + * Keys are unique identifiers for the provider instance, values are their + * respective configs. + */ +export type ProviderGroupConfig = { + [name: string]: ProviderConfig; +}; + +export type ProviderGroup = { + /** + * A map of combined provider outputs. Each key corresponds to a provider + * name, and each value is the output of that provider. + */ + outputMap: { + [TName in keyof T]: ProviderMap[T[TName]['type']]['output']; + }; + + /** + * A map of combined provider errors. Each key corresponds to a provider + * name, and each value is the error of that provider. + */ + errorMap: { + [TName in keyof T]: ProviderMap[T[TName]['type']]['error']; + }; + + /** + * Whether the latest emission from any provider in the group is an + * error. + */ + hasErrors: boolean; + + /** + * Underlying providers in the group. + */ + raw: { + [TName in keyof T]: ProviderMap[T[TName]['type']]; + }; + + /** + * Config for the provider group. + */ + configMap: T; + + /** + * Listens for outputs from any provider in the group. + * + * @param callback - Callback to run when an output is emitted. + */ + onOutput: ( + callback: (outputMap: ProviderGroup['outputMap']) => void, + ) => void; + + /** + * Listens for errors from any provider in the group. + * + * @param callback - Callback to run when an error is emitted. + */ + onError: ( + callback: (errorMap: ProviderGroup['errorMap']) => void, + ) => void; + + /** + * Restarts all providers in the group. + */ + restartAll(): Promise; + + /** + * Stops all providers in the group. + */ + stopAll(): Promise; +}; + +/** + * Docs {@link ZebarContext.createProviderGroup} + */ +export async function createProviderGroup( + configMap: T, +): Promise> { + const outputListeners = new Set< + (outputMap: ProviderGroup['outputMap']) => void + >(); + + const errorListeners = new Set< + (errorMap: ProviderGroup['errorMap']) => void + >(); + + const providerMap = await createProviderMap(configMap); + + let outputMap = {} as ProviderGroup['outputMap']; + let errorMap = {} as ProviderGroup['errorMap']; + + for (const [name, provider] of Object.entries(providerMap)) { + outputMap = { ...outputMap, [name]: provider.output }; + errorMap = { ...errorMap, [name]: provider.error }; + + provider.onOutput(() => { + outputMap = { ...outputMap, [name]: provider.output }; + outputListeners.forEach(listener => listener(outputMap)); + }); + + provider.onError(() => { + errorMap = { ...errorMap, [name]: provider.error }; + errorListeners.forEach(listener => listener(errorMap)); + }); + } + + return { + get outputMap() { + return outputMap; + }, + get errorMap() { + return errorMap; + }, + get hasErrors() { + return Object.keys(errorMap).length > 0; + }, + configMap, + raw: providerMap, + onOutput: callback => { + outputListeners.add(callback); + }, + onError: callback => { + errorListeners.add(callback); + }, + restartAll: async () => { + await Promise.all( + Object.values(providerMap).map(provider => provider.restart()), + ); + }, + stopAll: async () => { + outputListeners.clear(); + errorListeners.clear(); + + await Promise.all( + Object.values(providerMap).map(provider => provider.stop()), + ); + }, + }; +} + +async function createProviderMap( + configMap: T, +) { + const providerEntries = await Promise.all([ + ...Object.entries(configMap).map(async ([key, value]) => { + return [key, await createProvider(value)] as const; + }), + ]); + + return Object.fromEntries(providerEntries) as ProviderGroup['raw']; +} diff --git a/packages/client-api/src/providers/create-provider-listener.ts b/packages/client-api/src/providers/create-provider-listener.ts deleted file mode 100644 index c563a283..00000000 --- a/packages/client-api/src/providers/create-provider-listener.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - type Accessor, - createEffect, - createSignal, - onCleanup, - type Owner, - runWithOwner, -} from 'solid-js'; - -import { - onProviderEmit, - listenProvider, - unlistenProvider, -} from '~/desktop'; -import type { ProviderConfig } from '~/user-config'; -import { simpleHash } from '~/utils'; - -/** - * Utility for listening to a provider of a given config type. - */ -export function createProviderListener< - TConfig extends ProviderConfig, - TVars, ->(config: TConfig, owner: Owner): Promise> { - return new Promise(async resolve => { - const [payload, setPayload] = createSignal(); - - const configHash = simpleHash(config); - const unlisten = await onProviderEmit(configHash, setPayload); - - await listenProvider({ - configHash, - config, - trackedAccess: [], - }); - - runWithOwner(owner, () => { - onCleanup(() => { - unlisten(); - unlistenProvider(configHash); - }); - - createEffect(() => { - if (payload()) { - resolve(payload as Accessor); - } - }); - }); - }); -} diff --git a/packages/client-api/src/providers/create-provider.ts b/packages/client-api/src/providers/create-provider.ts index 5b6ec097..3b66bda3 100644 --- a/packages/client-api/src/providers/create-provider.ts +++ b/packages/client-api/src/providers/create-provider.ts @@ -1,57 +1,114 @@ -import type { Owner } from 'solid-js'; +import type { ZebarContext } from '~/zebar-context.model'; +import { + createBatteryProvider, + type BatteryProvider, + type BatteryProviderConfig, +} from './battery/create-battery-provider'; +import { + createCpuProvider, + type CpuProvider, + type CpuProviderConfig, +} from './cpu/create-cpu-provider'; +import { + createDateProvider, + type DateProvider, + type DateProviderConfig, +} from './date/create-date-provider'; +import { + createGlazeWmProvider, + type GlazeWmProvider, + type GlazeWmProviderConfig, +} from './glazewm/create-glazewm-provider'; +import { + createHostProvider, + type HostProvider, + type HostProviderConfig, +} from './host/create-host-provider'; +import { + createIpProvider, + type IpProvider, + type IpProviderConfig, +} from './ip/create-ip-provider'; +import { + createKomorebiProvider, + type KomorebiProvider, + type KomorebiProviderConfig, +} from './komorebi/create-komorebi-provider'; +import { + createMemoryProvider, + type MemoryProvider, + type MemoryProviderConfig, +} from './memory/create-memory-provider'; +import { + createNetworkProvider, + type NetworkProvider, + type NetworkProviderConfig, +} from './network/create-network-provider'; +import { + createWeatherProvider, + type WeatherProvider, + type WeatherProviderConfig, +} from './weather/create-weather-provider'; -import { createBatteryProvider } from './battery/create-battery-provider'; -import { createCpuProvider } from './cpu/create-cpu-provider'; -import { createDateProvider } from './date/create-date-provider'; -import { createGlazeWmProvider } from './glazewm/create-glazewm-provider'; -import { createHostProvider } from './host/create-host-provider'; -import { createIpProvider } from './ip/create-ip-provider'; -import { createKomorebiProvider } from './komorebi/create-komorebi-provider'; -import { createMemoryProvider } from './memory/create-memory-provider'; -import { createMonitorsProvider } from './monitors/create-monitors-provider'; -import { createNetworkProvider } from './network/create-network-provider'; -import { createSelfProvider } from './self/create-self-provider'; -import { createUtilProvider } from './util/create-util-provider'; -import { createWeatherProvider } from './weather/create-weather-provider'; -import { ProviderType, type ProviderConfig } from '~/user-config'; -import type { ElementContext } from '~/element-context.model'; -import type { PickPartial } from '~/utils'; +export interface ProviderConfigMap { + battery: BatteryProviderConfig; + cpu: CpuProviderConfig; + date: DateProviderConfig; + glazewm: GlazeWmProviderConfig; + host: HostProviderConfig; + ip: IpProviderConfig; + komorebi: KomorebiProviderConfig; + memory: MemoryProviderConfig; + network: NetworkProviderConfig; + weather: WeatherProviderConfig; +} + +export interface ProviderMap { + battery: BatteryProvider; + cpu: CpuProvider; + date: DateProvider; + glazewm: GlazeWmProvider; + host: HostProvider; + ip: IpProvider; + komorebi: KomorebiProvider; + memory: MemoryProvider; + network: NetworkProvider; + weather: WeatherProvider; +} + +export type ProviderType = keyof ProviderConfigMap; + +export type ProviderConfig = ProviderConfigMap[keyof ProviderConfigMap]; + +export type ProviderOutput = ProviderMap[keyof ProviderMap]['output']; -export async function createProvider( - elementContext: PickPartial< - ElementContext, - 'parsedConfig' | 'providers' - >, - config: ProviderConfig, - owner: Owner, -) { +/** + * Docs {@link ZebarContext.createProvider} + */ +export function createProvider( + config: T, +): Promise { switch (config.type) { - case ProviderType.BATTERY: - return createBatteryProvider(config, owner); - case ProviderType.CPU: - return createCpuProvider(config, owner); - case ProviderType.DATE: - return createDateProvider(config, owner); - case ProviderType.GLAZEWM: - return createGlazeWmProvider(config, owner); - case ProviderType.HOST: - return createHostProvider(config, owner); - case ProviderType.IP: - return createIpProvider(config, owner); - case ProviderType.KOMOREBI: - return createKomorebiProvider(config, owner); - case ProviderType.MEMORY: - return createMemoryProvider(config, owner); - case ProviderType.MONITORS: - return createMonitorsProvider(config, owner); - case ProviderType.NETWORK: - return createNetworkProvider(config, owner); - case ProviderType.SELF: - return createSelfProvider(elementContext); - case ProviderType.UTIL: - return createUtilProvider(config, owner); - case ProviderType.WEATHER: - return createWeatherProvider(config, owner); + case 'battery': + return createBatteryProvider(config) as any; + case 'cpu': + return createCpuProvider(config) as any; + case 'date': + return createDateProvider(config) as any; + case 'glazewm': + return createGlazeWmProvider(config) as any; + case 'host': + return createHostProvider(config) as any; + case 'ip': + return createIpProvider(config) as any; + case 'komorebi': + return createKomorebiProvider(config) as any; + case 'memory': + return createMemoryProvider(config) as any; + case 'network': + return createNetworkProvider(config) as any; + case 'weather': + return createWeatherProvider(config) as any; default: throw new Error('Not a supported provider type.'); } diff --git a/packages/client-api/src/providers/date/create-date-provider.ts b/packages/client-api/src/providers/date/create-date-provider.ts index b3f3001a..063614bd 100644 --- a/packages/client-api/src/providers/date/create-date-provider.ts +++ b/packages/client-api/src/providers/date/create-date-provider.ts @@ -1,10 +1,66 @@ import { DateTime } from 'luxon'; -import { type Owner, onCleanup, runWithOwner } from 'solid-js'; -import { createStore } from 'solid-js/store'; +import { z } from 'zod'; -import type { DateProviderConfig } from '~/user-config'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; + +export interface DateProviderConfig { + type: 'date'; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; + + /** + * Either a UTC offset (eg. `UTC+8`) or an IANA timezone (eg. + * `America/New_York`). Affects the output of {@link DateOutput.formatted}. + * + * A full list of available IANA timezones can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). + */ + timezone?: string; + + /** + * An ISO-639-1 locale, which is either a 2-letter language code + * (eg. `en`) or a 4-letter language + country code (eg. `en-gb`). + * Affects the output of {@link DateOutput.formatted}. + * + * A full list of ISO-639-1 locales can be found [here](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes#Table). + */ + locale?: string; + + /** + * Formatting of the current date into a custom string format. Affects + * the output of {@link DateOutput.formatted}. + * + * Refer to [table of tokens](https://moment.github.io/luxon/#/formatting?id=table-of-tokens) + * for available date/time tokens. + * + * @example + * "yyyy LLL dd" -> "2023 Feb 13" + * "HH 'hours and' mm 'minutes'" -> "20 hours and 55 minutes" + */ + formatting?: string; +} + +const dateProviderConfigSchema = z.object({ + type: z.literal('date'), + refreshInterval: z.coerce.number().default(1000), + timezone: z.string().default('local'), + locale: z.string().optional(), + formatting: z.string().default('EEE d MMM t'), +}); + +export type DateProvider = Provider; + +export interface DateOutput { + /** + * Current date/time as a formatted string. + */ + formatted: string; -export interface DateVariables { /** * Current date/time as a JavaScript `Date` object. Uses `new Date()` under * the hood. @@ -26,50 +82,32 @@ export interface DateVariables { export async function createDateProvider( config: DateProviderConfig, - owner: Owner, -) { - const [dateVariables, setDateVariables] = - createStore(getDateVariables()); - - const interval = setInterval( - () => setDateVariables(getDateVariables()), - config.refresh_interval, - ); - - runWithOwner(owner, () => { - onCleanup(() => clearInterval(interval)); - }); +): Promise { + const mergedConfig = dateProviderConfigSchema.parse(config); - function getDateVariables() { - const date = new Date(); + return createBaseProvider(mergedConfig, queue => { + queue.output(getDateValue()); - return { - new: date, - now: date.getTime(), - iso: date.toISOString(), - }; - } + const interval = setInterval( + () => queue.output(getDateValue()), + mergedConfig.refreshInterval, + ); - function toFormat(now: number, format: string) { - let dateTime = DateTime.fromMillis(now); + function getDateValue() { + const dateTime = DateTime.now().setZone(mergedConfig.timezone); - if (config.timezone) { - dateTime = dateTime.setZone(config.timezone); + return { + new: dateTime.toJSDate(), + now: dateTime.toMillis(), + iso: dateTime.toISO()!, + formatted: dateTime.toFormat(mergedConfig.formatting, { + locale: mergedConfig.locale, + }), + }; } - return dateTime.toFormat(format, { locale: config.locale }); - } - - return { - get new() { - return dateVariables.new; - }, - get now() { - return dateVariables.now; - }, - get iso() { - return dateVariables.iso; - }, - toFormat, - }; + return () => { + clearInterval(interval); + }; + }); } diff --git a/packages/client-api/src/providers/get-element-providers.ts b/packages/client-api/src/providers/get-element-providers.ts deleted file mode 100644 index d06bedfe..00000000 --- a/packages/client-api/src/providers/get-element-providers.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - type Accessor, - type Owner, - createComputed, - createSignal, - runWithOwner, -} from 'solid-js'; -import { createStore } from 'solid-js/store'; - -import { ProvidersConfigSchema, parseWithSchema } from '~/user-config'; -import type { ElementContext } from '~/element-context.model'; -import { createProvider } from './create-provider'; -import type { PickPartial } from '~/utils'; - -export async function getElementProviders( - elementContext: PickPartial< - ElementContext, - 'parsedConfig' | 'providers' - >, - ancestorProviders: Accessor>[], - owner: Owner, -) { - const [elementProviders, _] = createSignal(await getElementProviders()); - - const [mergedProviders, setMergedProviders] = createStore( - getMergedProviders(), - ); - - // Update the store on changes to any provider variables. - runWithOwner(owner, () => { - createComputed(() => setMergedProviders(getMergedProviders())); - }); - - /** - * Get map of element providers. - */ - async function getElementProviders() { - const providerConfigs = parseWithSchema( - ProvidersConfigSchema, - (elementContext.rawConfig as Record)?.providers ?? - [], - ); - - // Create tuple of configs and the created provider. - const providers = await Promise.all( - providerConfigs.map( - async config => - [ - config, - await createProvider(elementContext, config, owner), - ] as const, - ), - ); - - return providers.reduce( - (acc, [config, provider]) => ({ - ...acc, - [config.type]: provider, - }), - {}, - ); - } - - /** - * Get map of element providers merged with ancestor providers. - */ - function getMergedProviders() { - const mergedancestorProviders = (ancestorProviders ?? []).reduce( - (acc, vars) => ({ ...acc, ...vars() }), - {}, - ); - - return { - ...mergedancestorProviders, - ...elementProviders(), - }; - } - - return { - element: elementProviders, - merged: mergedProviders, - }; -} diff --git a/packages/client-api/src/providers/glazewm/create-glazewm-provider.ts b/packages/client-api/src/providers/glazewm/create-glazewm-provider.ts index 1bc69911..8715860d 100644 --- a/packages/client-api/src/providers/glazewm/create-glazewm-provider.ts +++ b/packages/client-api/src/providers/glazewm/create-glazewm-provider.ts @@ -8,20 +8,36 @@ import { type FocusChangedEvent, type FocusedContainerMovedEvent, type Monitor, + type RunCommandResponse, type TilingDirectionChangedEvent, type Workspace, type WorkspaceActivatedEvent, type WorkspaceDeactivatedEvent, type WorkspaceUpdatedEvent, } from 'glazewm'; -import { createEffect, on, runWithOwner, type Owner } from 'solid-js'; -import { createStore } from 'solid-js/store'; +import { z } from 'zod'; import { getMonitors } from '~/desktop'; -import type { GlazewmProviderConfig } from '~/user-config'; import { getCoordinateDistance } from '~/utils'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; + +export interface GlazeWmProviderConfig { + type: 'glazewm'; +} + +const glazeWmProviderConfigSchema = z.object({ + type: z.literal('glazewm'), +}); -export interface GlazeWmProvider { +export type GlazeWmProvider = Provider< + GlazeWmProviderConfig, + GlazeWmOutput +>; + +export interface GlazeWmOutput { /** * Workspace displayed on the current monitor. */ @@ -73,186 +89,169 @@ export interface GlazeWmProvider { bindingModes: BindingModeConfig[]; /** - * Focus a workspace by name. - */ - focusWorkspace(name: string): void; - - /** - * Toggle tiling direction. + * Invokes a WM command (e.g. `"focus --workspace 1"`). + * + * @param command WM command to run (e.g. `"focus --workspace 1"`). + * @param subjectContainerId (optional) ID of container to use as subject. + * If not provided, this defaults to the currently focused container. + * @throws If command fails. */ - toggleTilingDirection(): void; + runCommand( + command: string, + subjectContainerId?: string, + ): Promise; } export async function createGlazeWmProvider( - _: GlazewmProviderConfig, - owner: Owner, + config: GlazeWmProviderConfig, ): Promise { - const monitors = await getMonitors(); - const client = new WmClient(); - - const [glazeWmVariables, setGlazeWmVariables] = createStore( - await getInitialState(), - ); - - await client.subscribeMany( - [ - WmEventType.BINDING_MODES_CHANGED, - WmEventType.FOCUS_CHANGED, - WmEventType.FOCUSED_CONTAINER_MOVED, - WmEventType.TILING_DIRECTION_CHANGED, - WmEventType.WORKSPACE_ACTIVATED, - WmEventType.WORKSPACE_DEACTIVATED, - WmEventType.WORKSPACE_UPDATED, - ], - onEvent, - ); - - runWithOwner(owner, () => { - createEffect( - on( - () => monitors.currentMonitor, - async () => setGlazeWmVariables({ ...(await getMonitorState()) }), - ), - ); - }); + const mergedConfig = glazeWmProviderConfigSchema.parse(config); + + return createBaseProvider(mergedConfig, async queue => { + try { + const monitors = await getMonitors(); + const client = new WmClient(); + + let state = await getInitialState(); + queue.output(state); + + const unlisten = await client.subscribeMany( + [ + WmEventType.BINDING_MODES_CHANGED, + WmEventType.FOCUS_CHANGED, + WmEventType.FOCUSED_CONTAINER_MOVED, + WmEventType.TILING_DIRECTION_CHANGED, + WmEventType.WORKSPACE_ACTIVATED, + WmEventType.WORKSPACE_DEACTIVATED, + WmEventType.WORKSPACE_UPDATED, + ], + onEvent, + ); + + // TODO: Update state when monitors change. + // monitors.onChange(async () => { + // state = { ...state, ...(await getMonitorState()) }; + // queue.value(state); + // }); + + async function onEvent( + e: + | BindingModesChangedEvent + | FocusChangedEvent + | FocusedContainerMovedEvent + | TilingDirectionChangedEvent + | WorkspaceActivatedEvent + | WorkspaceDeactivatedEvent + | WorkspaceUpdatedEvent, + ) { + switch (e.eventType) { + case WmEventType.BINDING_MODES_CHANGED: { + state = { ...state, bindingModes: e.newBindingModes }; + break; + } + case WmEventType.FOCUS_CHANGED: { + state = { ...state, focusedContainer: e.focusedContainer }; + state = { ...state, ...(await getMonitorState()) }; + + const { tilingDirection } = + await client.queryTilingDirection(); + state = { ...state, tilingDirection }; + break; + } + case WmEventType.FOCUSED_CONTAINER_MOVED: { + state = { ...state, focusedContainer: e.focusedContainer }; + state = { ...state, ...(await getMonitorState()) }; + break; + } + case WmEventType.TILING_DIRECTION_CHANGED: { + state = { ...state, tilingDirection: e.newTilingDirection }; + break; + } + case WmEventType.WORKSPACE_ACTIVATED: + case WmEventType.WORKSPACE_DEACTIVATED: + case WmEventType.WORKSPACE_UPDATED: { + state = { ...state, ...(await getMonitorState()) }; + break; + } + } + + queue.output(state); + } - async function onEvent( - e: - | BindingModesChangedEvent - | FocusChangedEvent - | FocusedContainerMovedEvent - | TilingDirectionChangedEvent - | WorkspaceActivatedEvent - | WorkspaceDeactivatedEvent - | WorkspaceUpdatedEvent, - ) { - switch (e.eventType) { - case WmEventType.BINDING_MODES_CHANGED: { - setGlazeWmVariables({ bindingModes: e.newBindingModes }); - break; + function runCommand( + command: string, + subjectContainerId?: string, + ): Promise { + return client.runCommand(command, subjectContainerId); } - case WmEventType.FOCUS_CHANGED: { - setGlazeWmVariables({ focusedContainer: e.focusedContainer }); - setGlazeWmVariables({ ...(await getMonitorState()) }); + async function getInitialState() { + const { focused: focusedContainer } = await client.queryFocused(); + const { bindingModes } = await client.queryBindingModes(); const { tilingDirection } = await client.queryTilingDirection(); - setGlazeWmVariables({ tilingDirection }); - break; - } - case WmEventType.FOCUSED_CONTAINER_MOVED: { - setGlazeWmVariables({ focusedContainer: e.focusedContainer }); - setGlazeWmVariables({ ...(await getMonitorState()) }); - break; - } - case WmEventType.TILING_DIRECTION_CHANGED: { - setGlazeWmVariables({ tilingDirection: e.newTilingDirection }); - break; + + return { + ...(await getMonitorState()), + focusedContainer, + tilingDirection, + bindingModes, + runCommand, + }; } - case WmEventType.WORKSPACE_ACTIVATED: - case WmEventType.WORKSPACE_DEACTIVATED: - case WmEventType.WORKSPACE_UPDATED: { - setGlazeWmVariables({ ...(await getMonitorState()) }); - break; + + async function getMonitorState() { + const currentPosition = { + x: monitors.currentMonitor!.x, + y: monitors.currentMonitor!.y, + }; + + const { monitors: glazeWmMonitors } = await client.queryMonitors(); + + // Get GlazeWM monitor that corresponds to the Zebar window's monitor. + const currentGlazeWmMonitor = glazeWmMonitors.reduce((a, b) => + getCoordinateDistance(currentPosition, a) < + getCoordinateDistance(currentPosition, b) + ? a + : b, + ); + + const focusedGlazeWmMonitor = glazeWmMonitors.find( + monitor => monitor.hasFocus, + ); + + const allGlazeWmWorkspaces = glazeWmMonitors.flatMap( + monitor => monitor.children, + ); + + const focusedGlazeWmWorkspace = + focusedGlazeWmMonitor?.children.find( + workspace => workspace.hasFocus, + ); + + const displayedGlazeWmWorkspace = + currentGlazeWmMonitor.children.find( + workspace => workspace.isDisplayed, + ); + + return { + displayedWorkspace: displayedGlazeWmWorkspace!, + focusedWorkspace: focusedGlazeWmWorkspace!, + currentWorkspaces: currentGlazeWmMonitor.children, + allWorkspaces: allGlazeWmWorkspaces, + focusedMonitor: focusedGlazeWmMonitor!, + currentMonitor: currentGlazeWmMonitor, + allMonitors: glazeWmMonitors, + }; } + + return () => { + unlisten(); + client.closeConnection(); + }; + } catch (err) { + // TODO: Implement retries. + queue.error((err as Error).message); + return () => {}; } - } - - async function getInitialState() { - const { focused: focusedContainer } = await client.queryFocused(); - const { bindingModes } = await client.queryBindingModes(); - const { tilingDirection } = await client.queryTilingDirection(); - - return { - ...(await getMonitorState()), - focusedContainer, - tilingDirection, - bindingModes, - }; - } - - async function getMonitorState() { - const currentPosition = { - x: monitors.currentMonitor!.x, - y: monitors.currentMonitor!.y, - }; - - const { monitors: glazeWmMonitors } = await client.queryMonitors(); - - // Get GlazeWM monitor that corresponds to the Zebar window's monitor. - const currentGlazeWmMonitor = glazeWmMonitors.reduce((a, b) => - getCoordinateDistance(currentPosition, a) < - getCoordinateDistance(currentPosition, b) - ? a - : b, - ); - - const focusedGlazeWmMonitor = glazeWmMonitors.find( - monitor => monitor.hasFocus, - ); - - const allGlazeWmWorkspaces = glazeWmMonitors.flatMap( - monitor => monitor.children, - ); - - const focusedGlazeWmWorkspace = focusedGlazeWmMonitor?.children.find( - workspace => workspace.hasFocus, - ); - - const displayedGlazeWmWorkspace = currentGlazeWmMonitor.children.find( - workspace => workspace.isDisplayed, - ); - - return { - displayedWorkspace: displayedGlazeWmWorkspace!, - focusedWorkspace: focusedGlazeWmWorkspace!, - currentWorkspaces: currentGlazeWmMonitor.children, - allWorkspaces: allGlazeWmWorkspaces, - focusedMonitor: focusedGlazeWmMonitor!, - currentMonitor: currentGlazeWmMonitor, - allMonitors: glazeWmMonitors, - }; - } - - function focusWorkspace(name: string) { - client.runCommand(`focus --workspace ${name}`); - } - - function toggleTilingDirection() { - client.runCommand('toggle-tiling-direction'); - } - - return { - get displayedWorkspace() { - return glazeWmVariables.displayedWorkspace; - }, - get focusedWorkspace() { - return glazeWmVariables.focusedWorkspace; - }, - get currentWorkspaces() { - return glazeWmVariables.currentWorkspaces; - }, - get allWorkspaces() { - return glazeWmVariables.allWorkspaces; - }, - get allMonitors() { - return glazeWmVariables.allMonitors; - }, - get focusedMonitor() { - return glazeWmVariables.focusedMonitor; - }, - get currentMonitor() { - return glazeWmVariables.currentMonitor; - }, - get focusedContainer() { - return glazeWmVariables.focusedContainer; - }, - get tilingDirection() { - return glazeWmVariables.tilingDirection; - }, - get bindingModes() { - return glazeWmVariables.bindingModes; - }, - focusWorkspace, - toggleTilingDirection, - }; + }); } diff --git a/packages/client-api/src/providers/host/create-host-provider.ts b/packages/client-api/src/providers/host/create-host-provider.ts index 51b95c2c..77a1a21b 100644 --- a/packages/client-api/src/providers/host/create-host-provider.ts +++ b/packages/client-api/src/providers/host/create-host-provider.ts @@ -1,9 +1,28 @@ -import type { Owner } from 'solid-js'; +import { z } from 'zod'; -import type { HostProviderConfig } from '~/user-config'; -import { createProviderListener } from '../create-provider-listener'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; -export interface HostVariables { +export interface HostProviderConfig { + type: 'host'; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; +} + +const hostProviderConfigSchema = z.object({ + type: z.literal('host'), + refreshInterval: z.coerce.number().default(60 * 1000), +}); + +export type HostProvider = Provider; + +export interface HostOutput { hostname: string | null; osName: string | null; osVersion: string | null; @@ -14,31 +33,16 @@ export interface HostVariables { export async function createHostProvider( config: HostProviderConfig, - owner: Owner, -) { - const hostVariables = await createProviderListener< - HostProviderConfig, - HostVariables - >(config, owner); - - return { - get hostname() { - return hostVariables().hostname; - }, - get osName() { - return hostVariables().osName; - }, - get osVersion() { - return hostVariables().osVersion; - }, - get friendlyOsVersion() { - return hostVariables().friendlyOsVersion; - }, - get bootTime() { - return hostVariables().bootTime; - }, - get uptime() { - return hostVariables().uptime; - }, - }; +): Promise { + const mergedConfig = hostProviderConfigSchema.parse(config); + + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); } diff --git a/packages/client-api/src/providers/index.ts b/packages/client-api/src/providers/index.ts index d0106b1c..008fb252 100644 --- a/packages/client-api/src/providers/index.ts +++ b/packages/client-api/src/providers/index.ts @@ -1,13 +1,2 @@ -export * from './battery/create-battery-provider'; -export * from './cpu/create-cpu-provider'; -export * from './date/create-date-provider'; -export * from './glazewm/create-glazewm-provider'; -export * from './ip/create-ip-provider'; -export * from './memory/create-memory-provider'; -export * from './network/create-network-provider'; -export * from './self/create-self-provider'; -export * from './util/create-util-provider'; -export * from './weather/create-weather-provider'; -export * from './create-provider-listener'; export * from './create-provider'; -export * from './get-element-providers'; +export * from './create-provider-group'; diff --git a/packages/client-api/src/providers/ip/create-ip-provider.ts b/packages/client-api/src/providers/ip/create-ip-provider.ts index ded4ec02..496ddce2 100644 --- a/packages/client-api/src/providers/ip/create-ip-provider.ts +++ b/packages/client-api/src/providers/ip/create-ip-provider.ts @@ -1,9 +1,28 @@ -import type { Owner } from 'solid-js'; +import { z } from 'zod'; -import type { IpProviderConfig } from '~/user-config'; -import { createProviderListener } from '../create-provider-listener'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; -export interface IpVariables { +export interface IpProviderConfig { + type: 'ip'; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; +} + +const ipProviderConfigSchema = z.object({ + type: z.literal('ip'), + refreshInterval: z.coerce.number().default(60 * 60 * 1000), +}); + +export type IpProvider = Provider; + +export interface IpOutput { address: string; approxCity: string; approxCountry: string; @@ -13,28 +32,16 @@ export interface IpVariables { export async function createIpProvider( config: IpProviderConfig, - owner: Owner, -) { - const ipVariables = await createProviderListener< - IpProviderConfig, - IpVariables - >(config, owner); - - return { - get address() { - return ipVariables().address; - }, - get approxCity() { - return ipVariables().approxCity; - }, - get approxCountry() { - return ipVariables().approxCountry; - }, - get approxLatitude() { - return ipVariables().approxLatitude; - }, - get approxLongitude() { - return ipVariables().approxLongitude; - }, - }; +): Promise { + const mergedConfig = ipProviderConfigSchema.parse(config); + + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); } diff --git a/packages/client-api/src/providers/komorebi/create-komorebi-provider.ts b/packages/client-api/src/providers/komorebi/create-komorebi-provider.ts index e498f64f..699b8ddf 100644 --- a/packages/client-api/src/providers/komorebi/create-komorebi-provider.ts +++ b/packages/client-api/src/providers/komorebi/create-komorebi-provider.ts @@ -1,17 +1,26 @@ -import { createEffect, runWithOwner, type Owner } from 'solid-js'; -import { createStore } from 'solid-js/store'; +import { z } from 'zod'; -import type { KomorebiProviderConfig } from '~/user-config'; -import { createProviderListener } from '../create-provider-listener'; -import { getMonitors } from '~/desktop'; +import { getMonitors, onProviderEmit } from '~/desktop'; import { getCoordinateDistance } from '~/utils'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; -interface KomorebiResponse { - allMonitors: KomorebiMonitor[]; - focusedMonitorIndex: number; +export interface KomorebiProviderConfig { + type: 'komorebi'; } -export interface KomorebiProvider { +const komorebiProviderConfigSchema = z.object({ + type: z.literal('komorebi'), +}); + +export type KomorebiProvider = Provider< + KomorebiProviderConfig, + KomorebiOutput +>; + +export interface KomorebiOutput { /** * Workspace displayed on the current monitor. */ @@ -48,6 +57,11 @@ export interface KomorebiProvider { currentMonitor: KomorebiMonitor; } +interface KomorebiResponse { + allMonitors: KomorebiMonitor[]; + focusedMonitorIndex: number; +} + export interface KomorebiMonitor { id: number; deviceId: string; @@ -109,94 +123,71 @@ export type KomorebiLayoutFlip = export async function createKomorebiProvider( config: KomorebiProviderConfig, - owner: Owner, ): Promise { - const monitors = await getMonitors(); - - const providerListener = await createProviderListener< - KomorebiProviderConfig, - KomorebiResponse - >(config, owner); - - const [komorebiVariables, setKomorebiVariables] = createStore( - await getVariables(), - ); - - runWithOwner(owner, () => { - createEffect(async () => setKomorebiVariables(await getVariables())); - }); - - async function getVariables() { - const state = providerListener(); - - const currentPosition = { - x: monitors.currentMonitor!.x, - y: monitors.currentMonitor!.y, - }; - - // Get Komorebi monitor that corresponds to the Zebar window's monitor. - const currentKomorebiMonitor = state.allMonitors.reduce((a, b) => - getCoordinateDistance(currentPosition, { - x: a.workAreaSize.left, - y: a.workAreaSize.top, - }) < - getCoordinateDistance(currentPosition, { - x: b.workAreaSize.left, - y: b.workAreaSize.top, - }) - ? a - : b, + const mergedConfig = komorebiProviderConfigSchema.parse(config); + + // TODO: Update state when monitors change. + return createBaseProvider(mergedConfig, async queue => { + const monitors = await getMonitors(); + + async function getUpdatedState(res: KomorebiResponse) { + const currentPosition = { + x: monitors.currentMonitor!.x, + y: monitors.currentMonitor!.y, + }; + + // Get Komorebi monitor that corresponds to the Zebar window's monitor. + const currentKomorebiMonitor = res.allMonitors.reduce((a, b) => + getCoordinateDistance(currentPosition, { + x: a.workAreaSize.left, + y: a.workAreaSize.top, + }) < + getCoordinateDistance(currentPosition, { + x: b.workAreaSize.left, + y: b.workAreaSize.top, + }) + ? a + : b, + ); + + const displayedKomorebiWorkspace = + currentKomorebiMonitor.workspaces[ + currentKomorebiMonitor.focusedWorkspaceIndex + ]!; + + const allKomorebiWorkspaces = res.allMonitors.flatMap( + monitor => monitor.workspaces, + ); + + const focusedKomorebiMonitor = + res.allMonitors[res.focusedMonitorIndex]!; + + const focusedKomorebiWorkspace = + focusedKomorebiMonitor.workspaces[ + focusedKomorebiMonitor.focusedWorkspaceIndex + ]!; + + return { + displayedWorkspace: displayedKomorebiWorkspace, + focusedWorkspace: focusedKomorebiWorkspace, + currentWorkspaces: currentKomorebiMonitor.workspaces, + allWorkspaces: allKomorebiWorkspaces, + focusedMonitor: focusedKomorebiMonitor, + currentMonitor: currentKomorebiMonitor, + allMonitors: res.allMonitors, + }; + } + + return onProviderEmit( + mergedConfig, + async ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + const updatedState = await getUpdatedState(result.output); + queue.output(updatedState); + } + }, ); - - const displayedKomorebiWorkspace = - currentKomorebiMonitor.workspaces[ - currentKomorebiMonitor.focusedWorkspaceIndex - ]!; - - const allKomorebiWorkspaces = state.allMonitors.flatMap( - monitor => monitor.workspaces, - ); - - const focusedKomorebiMonitor = - state.allMonitors[state.focusedMonitorIndex]!; - - const focusedKomorebiWorkspace = - focusedKomorebiMonitor.workspaces[ - focusedKomorebiMonitor.focusedWorkspaceIndex - ]!; - - return { - displayedWorkspace: displayedKomorebiWorkspace, - focusedWorkspace: focusedKomorebiWorkspace, - currentWorkspaces: currentKomorebiMonitor.workspaces, - allWorkspaces: allKomorebiWorkspaces, - focusedMonitor: focusedKomorebiMonitor, - currentMonitor: currentKomorebiMonitor, - allMonitors: state.allMonitors, - }; - } - - return { - get displayedWorkspace() { - return komorebiVariables.displayedWorkspace; - }, - get focusedWorkspace() { - return komorebiVariables.focusedWorkspace; - }, - get currentWorkspaces() { - return komorebiVariables.currentWorkspaces; - }, - get allWorkspaces() { - return komorebiVariables.allWorkspaces; - }, - get allMonitors() { - return komorebiVariables.allMonitors; - }, - get focusedMonitor() { - return komorebiVariables.focusedMonitor; - }, - get currentMonitor() { - return komorebiVariables.currentMonitor; - }, - }; + }); } diff --git a/packages/client-api/src/providers/memory/create-memory-provider.ts b/packages/client-api/src/providers/memory/create-memory-provider.ts index 00302129..102bd69c 100644 --- a/packages/client-api/src/providers/memory/create-memory-provider.ts +++ b/packages/client-api/src/providers/memory/create-memory-provider.ts @@ -1,9 +1,28 @@ -import type { Owner } from 'solid-js'; +import { z } from 'zod'; -import type { MemoryProviderConfig } from '~/user-config'; -import { createProviderListener } from '../create-provider-listener'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; -export interface MemoryVariables { +export interface MemoryProviderConfig { + type: 'memory'; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; +} + +const memoryProviderConfigSchema = z.object({ + type: z.literal('memory'), + refreshInterval: z.coerce.number().default(5 * 1000), +}); + +export type MemoryProvider = Provider; + +export interface MemoryOutput { usage: number; freeMemory: number; usedMemory: number; @@ -15,34 +34,16 @@ export interface MemoryVariables { export async function createMemoryProvider( config: MemoryProviderConfig, - owner: Owner, -) { - const memoryVariables = await createProviderListener< - MemoryProviderConfig, - MemoryVariables - >(config, owner); - - return { - get usage() { - return memoryVariables().usage; - }, - get freeMemory() { - return memoryVariables().freeMemory; - }, - get usedMemory() { - return memoryVariables().usedMemory; - }, - get totalMemory() { - return memoryVariables().totalMemory; - }, - get freeSwap() { - return memoryVariables().freeSwap; - }, - get usedSwap() { - return memoryVariables().usedSwap; - }, - get totalSwap() { - return memoryVariables().totalSwap; - }, - }; +): Promise { + const mergedConfig = memoryProviderConfigSchema.parse(config); + + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); } diff --git a/packages/client-api/src/providers/monitors/create-monitors-provider.ts b/packages/client-api/src/providers/monitors/create-monitors-provider.ts deleted file mode 100644 index 37b63bc5..00000000 --- a/packages/client-api/src/providers/monitors/create-monitors-provider.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Owner } from 'solid-js'; - -import { type MonitorInfo, getMonitors } from '~/desktop'; -import type { MonitorsProviderConfig } from '~/user-config'; - -export interface MonitorsVariables { - primary?: MonitorInfo; - secondary: MonitorInfo[]; - all: MonitorInfo[]; -} - -export async function createMonitorsProvider( - _: MonitorsProviderConfig, - __: Owner, -) { - const { primaryMonitor, secondaryMonitors, allMonitors } = - await getMonitors(); - - return { - get primary() { - return primaryMonitor; - }, - get secondary() { - return secondaryMonitors; - }, - get all() { - return allMonitors; - }, - }; -} diff --git a/packages/client-api/src/providers/network/create-network-provider.ts b/packages/client-api/src/providers/network/create-network-provider.ts index 712de622..012faaf4 100644 --- a/packages/client-api/src/providers/network/create-network-provider.ts +++ b/packages/client-api/src/providers/network/create-network-provider.ts @@ -1,9 +1,31 @@ -import type { Owner } from 'solid-js'; +import { z } from 'zod'; -import type { NetworkProviderConfig } from '~/user-config'; -import { createProviderListener } from '../create-provider-listener'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; -export interface NetworkVariables { +export interface NetworkProviderConfig { + type: 'network'; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; +} + +const networkProviderConfigSchema = z.object({ + type: z.literal('network'), + refreshInterval: z.coerce.number().default(5 * 1000), +}); + +export type NetworkProvider = Provider< + NetworkProviderConfig, + NetworkOutput +>; + +export interface NetworkOutput { defaultInterface: NetworkInterface | null; defaultGateway: NetworkGateway | null; interfaces: NetworkInterface[]; @@ -52,31 +74,30 @@ export enum InterfaceType { } export interface NetworkTraffic { - received: number | null; - transmitted: number | null; + received: NetworkTrafficMeasure; + transmitted: NetworkTrafficMeasure; +} + +export interface NetworkTrafficMeasure { + bytes: number; + siValue: number; + siUnit: string; + iecValue: number; + iecUnit: string; } export async function createNetworkProvider( config: NetworkProviderConfig, - owner: Owner, -) { - const networkVariables = await createProviderListener< - NetworkProviderConfig, - NetworkVariables - >(config, owner); +): Promise { + const mergedConfig = networkProviderConfigSchema.parse(config); - return { - get defaultInterface() { - return networkVariables().defaultInterface; - }, - get defaultGateway() { - return networkVariables().defaultGateway; - }, - get interfaces() { - return networkVariables().interfaces; - }, - get traffic() { - return networkVariables().traffic; - }, - }; + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); } diff --git a/packages/client-api/src/providers/self/create-self-provider.ts b/packages/client-api/src/providers/self/create-self-provider.ts deleted file mode 100644 index 21481b1c..00000000 --- a/packages/client-api/src/providers/self/create-self-provider.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ElementContext } from '~/element-context.model'; -import type { PickPartial } from '~/utils'; - -export type SelfProvider = PickPartial< - ElementContext, - 'parsedConfig' | 'providers' ->; - -export async function createSelfProvider( - elementContext: PickPartial< - ElementContext, - 'parsedConfig' | 'providers' - >, -): Promise { - return elementContext; -} diff --git a/packages/client-api/src/providers/util/create-util-provider.ts b/packages/client-api/src/providers/util/create-util-provider.ts deleted file mode 100644 index 1f4ced90..00000000 --- a/packages/client-api/src/providers/util/create-util-provider.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Owner } from 'solid-js'; - -import type { UtilProviderConfig } from '~/user-config'; - -export enum DataUnit { - BITS = 'bits', - SI_BYTES = 'si_bytes', - IEC_BYTES = 'iec_bytes', -} - -export async function createUtilProvider( - _: UtilProviderConfig, - __: Owner, -) { - const bitUnits = ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb']; - - const byteCommonUnits = [ - 'B', - 'KB', - 'MB', - 'GB', - 'TB', - 'PB', - 'EB', - 'ZB', - 'YB', - ]; - - const byteIECUnits = [ - 'B', - 'KiB', - 'MiB', - 'GiB', - 'TiB', - 'PiB', - 'EiB', - 'ZiB', - 'YiB', - ]; - - function convertBytes( - bytes: number, - decimals: number = 0, - unitType: DataUnit = DataUnit.BITS, - ) { - let unitIndex = 1; // Kb/KB/KiB - - if (unitType === DataUnit.BITS) { - bytes *= 8; - return convert(1000, bitUnits, bytes, decimals, unitIndex); - } - - if (unitType === DataUnit.SI_BYTES) { - return convert(1000, byteCommonUnits, bytes, decimals, unitIndex); - } - - if (unitType === DataUnit.IEC_BYTES) { - return convert(1024, byteIECUnits, bytes, decimals, unitIndex); - } - - return 'NoUnit'; - } - - function convert( - k: number, - units: string[], - bytes: number, - decimals: number, - unitIndex: number, - ) { - const dm = decimals < 0 ? 0 : decimals; - - if (!+bytes) { - return `${(0.0).toFixed(dm)} ${units[unitIndex]}`; - } - - let i = Math.floor(Math.log(bytes) / Math.log(k)); - - if (i < unitIndex) { - i = unitIndex; - } - - return `${(bytes / Math.pow(k, i)).toFixed(dm)} ${units[i]?.trimStart()}`; - } - - return { - convertBytes, - }; -} diff --git a/packages/client-api/src/providers/weather/create-weather-provider.ts b/packages/client-api/src/providers/weather/create-weather-provider.ts index ab6f4b5f..176d8592 100644 --- a/packages/client-api/src/providers/weather/create-weather-provider.ts +++ b/packages/client-api/src/providers/weather/create-weather-provider.ts @@ -1,14 +1,48 @@ -import type { Owner } from 'solid-js'; +import { z } from 'zod'; -import { ProviderType, type WeatherProviderConfig } from '~/user-config'; -import { - type IpVariables, - createIpProvider, -} from '../ip/create-ip-provider'; +import type { IpProvider } from '../ip/create-ip-provider'; import { WeatherStatus } from './weather-status.enum'; -import { createProviderListener } from '../create-provider-listener'; +import { + createBaseProvider, + type Provider, +} from '../create-base-provider'; +import { onProviderEmit } from '~/desktop'; +import { createProvider } from '../create-provider'; + +export interface WeatherProviderConfig { + type: 'weather'; -export interface WeatherVariables { + /** + * Latitude to retrieve weather for. If not provided, latitude is instead + * estimated based on public IP. + */ + latitude?: number; + + /** + * Longitude to retrieve weather for. If not provided, longitude is instead + * estimated based on public IP. + */ + longitude?: number; + + /** + * How often this provider refreshes in milliseconds. + */ + refreshInterval?: number; +} + +const weatherProviderConfigSchema = z.object({ + type: z.literal('weather'), + latitude: z.coerce.number().optional(), + longitude: z.coerce.number().optional(), + refreshInterval: z.coerce.number().default(60 * 60 * 1000), +}); + +export type WeatherProvider = Provider< + WeatherProviderConfig, + WeatherOutput +>; + +export interface WeatherOutput { isDaytime: boolean; status: WeatherStatus; celsiusTemp: number; @@ -18,49 +52,30 @@ export interface WeatherVariables { export async function createWeatherProvider( config: WeatherProviderConfig, - owner: Owner, -) { - let ipProvider: IpVariables | null = null; +): Promise { + let ipProvider: IpProvider | null = null; - const mergedConfig = { - ...config, - longitude: config.longitude ?? (await getIpProvider()).approxLongitude, - latitude: config.latitude ?? (await getIpProvider()).approxLatitude, + const mergedConfig: WeatherProviderConfig = { + ...weatherProviderConfigSchema.parse(config), + longitude: + config.longitude ?? (await getIpProvider()).output!.approxLongitude, + latitude: + config.latitude ?? (await getIpProvider()).output!.approxLatitude, }; - const weatherVariables = await createProviderListener< - WeatherProviderConfig, - WeatherVariables - >(mergedConfig, owner); - async function getIpProvider() { return ( - ipProvider ?? - (ipProvider = await createIpProvider( - { - type: ProviderType.IP, - refresh_interval: 60 * 60 * 1000, - }, - owner, - )) + ipProvider ?? (ipProvider = await createProvider({ type: 'ip' })) ); } - return { - get isDaytime() { - return weatherVariables().isDaytime; - }, - get status() { - return weatherVariables().status; - }, - get celsiusTemp() { - return weatherVariables().celsiusTemp; - }, - get fahrenheitTemp() { - return weatherVariables().fahrenheitTemp; - }, - get windSpeed() { - return weatherVariables().windSpeed; - }, - }; + return createBaseProvider(mergedConfig, async queue => { + return onProviderEmit(mergedConfig, ({ result }) => { + if ('error' in result) { + queue.error(result.error); + } else { + queue.output(result.output); + } + }); + }); } diff --git a/packages/client-api/src/shims.d.ts b/packages/client-api/src/shims.d.ts index 57f930c1..b1dc5a88 100644 --- a/packages/client-api/src/shims.d.ts +++ b/packages/client-api/src/shims.d.ts @@ -1,9 +1,9 @@ interface Window { __TAURI__: any; - __ZEBAR_OPEN_ARGS: import('./desktop').OpenWindowArgs; + __ZEBAR_INITIAL_STATE: import('./desktop').WindowState; } -declare module '*.html' { +declare module '*.css' { const src: string; export default src; } diff --git a/packages/client-api/src/template-engine/get-template-engine.ts b/packages/client-api/src/template-engine/get-template-engine.ts deleted file mode 100644 index 07cd51a1..00000000 --- a/packages/client-api/src/template-engine/get-template-engine.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createStore, reconcile } from 'solid-js/store'; - -import { type TemplateNode, parseTokens } from './token-parsing'; -import { renderTemplateNodes } from './rendering'; -import { tokenizeTemplate } from './tokenizing'; - -const [cache, setCache] = createStore>({}); - -export interface TemplateEngine { - render: (template: string, context: Record) => string; - clearCache: () => void; -} - -export function getTemplateEngine(): TemplateEngine { - return { - render, - clearCache, - }; -} - -function render(template: string, context: Record) { - const cacheHit = cache[template]; - - if (cacheHit) { - return renderTemplateNodes(cacheHit, context); - } - - // Tokenize and parse the template. Cache the result. - const tokens = tokenizeTemplate(template); - const parsed = parseTokens(tokens); - setCache(template, parsed); - - return renderTemplateNodes(parsed, context); -} - -function clearCache() { - setCache(reconcile({})); -} diff --git a/packages/client-api/src/template-engine/index.ts b/packages/client-api/src/template-engine/index.ts deleted file mode 100644 index fa438aba..00000000 --- a/packages/client-api/src/template-engine/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './rendering'; -export * from './shared'; -export * from './token-parsing'; -export * from './tokenizing'; -export * from './get-template-engine'; diff --git a/packages/client-api/src/template-engine/rendering/index.ts b/packages/client-api/src/template-engine/rendering/index.ts deleted file mode 100644 index abd9a129..00000000 --- a/packages/client-api/src/template-engine/rendering/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './render-template-nodes'; diff --git a/packages/client-api/src/template-engine/rendering/render-template-nodes.ts b/packages/client-api/src/template-engine/rendering/render-template-nodes.ts deleted file mode 100644 index c96320d1..00000000 --- a/packages/client-api/src/template-engine/rendering/render-template-nodes.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - type ForStatementNode, - type IfStatementNode, - type InterpolationNode, - type SwitchStatementNode, - type TemplateNode, - TemplateNodeType, - type TextNode, -} from '../token-parsing'; -import { TemplateError } from '../shared'; - -export interface RenderTransforms { - transformExpression?: (expression: unknown) => unknown; -} - -export interface RenderContext { - global: Record; - local: Record[]; -} - -/** Pattern for the expression in a for loop statement. */ -const FOR_LOOP_EXPRESSION_PATTERN = - /^\s*([(),\s0-9A-Za-z_$]*)\s+of\s+(.*)/; - -/** Pattern for the loop variable on the left-side of a for loop expression. */ -const FOR_LOOP_VARIABLE_PATTERN = - /^\(?\s*([0-9A-Za-z_$]*)\s*,?\s*([0-9A-Za-z_$]*)/; - -/** - * Takes an abstract syntax tree and renders it to a string. - */ -export function renderTemplateNodes( - nodes: TemplateNode[], - globalContext: Record, -) { - const context: RenderContext = { - global: globalContext, - local: [], - }; - - function visitAll(nodes: TemplateNode[]): string { - return nodes.map(node => visitOne(node)).join(''); - } - - function visitOne(node: TemplateNode): string { - switch (node.type) { - case TemplateNodeType.TEXT: - return visitTextNode(node); - case TemplateNodeType.INTERPOLATION: - return visitInterpolationNode(node); - case TemplateNodeType.IF_STATEMENT: - return visitIfStatementNode(node); - case TemplateNodeType.FOR_STATEMENT: - return visitForStatementNode(node); - case TemplateNodeType.SWITCH_STATEMENT: - return visitSwitchStatementNode(node); - } - } - - function visitTextNode(node: TextNode): string { - return node.text; - } - - function visitInterpolationNode(node: InterpolationNode): string { - return evalExpression(node.expression); - } - - function visitIfStatementNode(node: IfStatementNode): string { - for (const branch of node.branches) { - const shouldVisit = - branch.type === 'else' || - Boolean(evalExpression(branch.expression)); - - if (shouldVisit) { - return visitAll(branch.children); - } - } - - return ''; - } - - function visitForStatementNode(node: ForStatementNode): string { - const { loopVariable, indexVariable, iterable } = parseForExpression( - node.expression, - ); - - return iterable - .map((el, index) => { - // Push loop variable and index (optionally) to local context. - context.local.push({ - [loopVariable]: el, - ...(indexVariable ? { [indexVariable]: index } : {}), - }); - - const result = visitAll(node.children); - context.local.pop(); - - return result; - }) - .join(''); - } - - function parseForExpression(expression: string) { - try { - const expressionMatch = expression.match( - FOR_LOOP_EXPRESSION_PATTERN, - ); - const [_, loopVariableExpression, iterable] = expressionMatch ?? []; - - if (!loopVariableExpression || !iterable) { - throw new Error(); - } - - const loopVariableMatch = loopVariableExpression.match( - FOR_LOOP_VARIABLE_PATTERN, - ); - const [__, loopVariable, indexVariable] = loopVariableMatch ?? []; - - if (!loopVariable) { - throw new Error(); - } - - return { - loopVariable, - indexVariable, - iterable: evalExpression(iterable) as unknown[], - }; - } catch (err) { - throw new TemplateError( - "@for loop doesn't have a valid expression. Must be in the format '@for (item of items) { ... }'.", - 0, - ); - } - } - - function visitSwitchStatementNode(node: SwitchStatementNode): string { - const value = evalExpression(node.expression); - - for (const branch of node.branches) { - const shouldVisit = - branch.type === 'default' || - value === evalExpression(branch.expression); - - if (shouldVisit) { - return visitAll(branch.children); - } - } - - return ''; - } - - function evalExpression(expression: string) { - const evalFn = new Function( - 'global', - 'local', - `with (global) { with (local) { return ${expression} } }`, - ); - - return evalFn( - context.global, - context.local.reduce((acc, e) => ({ ...acc, ...e }), {}), - ); - } - - // Render and trim any leading/trailing whitespace. - return visitAll(nodes).trim(); -} diff --git a/packages/client-api/src/template-engine/shared/index.ts b/packages/client-api/src/template-engine/shared/index.ts deleted file mode 100644 index 8c094aeb..00000000 --- a/packages/client-api/src/template-engine/shared/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './template-error'; diff --git a/packages/client-api/src/template-engine/shared/template-error.ts b/packages/client-api/src/template-engine/shared/template-error.ts deleted file mode 100644 index af9be6c4..00000000 --- a/packages/client-api/src/template-engine/shared/template-error.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class TemplateError extends Error { - public templateIndex: number; - - constructor(message: string, templateIndex: number) { - super(message); - this.templateIndex = templateIndex; - } -} diff --git a/packages/client-api/src/template-engine/token-parsing/for-statement-node.ts b/packages/client-api/src/template-engine/token-parsing/for-statement-node.ts deleted file mode 100644 index 323fc647..00000000 --- a/packages/client-api/src/template-engine/token-parsing/for-statement-node.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { TemplateNode } from './template-node.model'; -import { TemplateNodeType } from './template-node-type.model'; - -export interface ForStatementNode { - type: TemplateNodeType.FOR_STATEMENT; - expression: string; - children: TemplateNode[]; -} diff --git a/packages/client-api/src/template-engine/token-parsing/if-statement-node.model.ts b/packages/client-api/src/template-engine/token-parsing/if-statement-node.model.ts deleted file mode 100644 index d97c822e..00000000 --- a/packages/client-api/src/template-engine/token-parsing/if-statement-node.model.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TemplateNodeType } from './template-node-type.model'; -import type { TemplateNode } from './template-node.model'; - -export interface IfBranch { - type: 'if' | 'else if'; - expression: string; - children: TemplateNode[]; -} - -export interface ElseBranch { - type: 'else'; - expression: null; - children: TemplateNode[]; -} - -export interface IfStatementNode { - type: TemplateNodeType.IF_STATEMENT; - branches: (IfBranch | ElseBranch)[]; -} diff --git a/packages/client-api/src/template-engine/token-parsing/index.ts b/packages/client-api/src/template-engine/token-parsing/index.ts deleted file mode 100644 index 2ef36488..00000000 --- a/packages/client-api/src/template-engine/token-parsing/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './for-statement-node'; -export * from './if-statement-node.model'; -export * from './interpolation-node.model'; -export * from './parse-tokens'; -export * from './switch-statement-node.model'; -export * from './template-node-type.model'; -export * from './template-node.model'; -export * from './text-node.model'; diff --git a/packages/client-api/src/template-engine/token-parsing/interpolation-node.model.ts b/packages/client-api/src/template-engine/token-parsing/interpolation-node.model.ts deleted file mode 100644 index 5dd191c1..00000000 --- a/packages/client-api/src/template-engine/token-parsing/interpolation-node.model.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { TemplateNodeType } from './template-node-type.model'; - -export interface InterpolationNode { - type: TemplateNodeType.INTERPOLATION; - expression: string; -} diff --git a/packages/client-api/src/template-engine/token-parsing/parse-tokens.ts b/packages/client-api/src/template-engine/token-parsing/parse-tokens.ts deleted file mode 100644 index e067d8c1..00000000 --- a/packages/client-api/src/template-engine/token-parsing/parse-tokens.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { TemplateError } from '../shared'; -import { type Token, TokenType } from '../tokenizing'; -import type { ForStatementNode } from './for-statement-node'; -import type { - IfStatementNode, - IfBranch, - ElseBranch, -} from './if-statement-node.model'; -import type { InterpolationNode } from './interpolation-node.model'; -import type { - SwitchStatementNode, - CaseBranch, - DefaultBranch, -} from './switch-statement-node.model'; -import { TemplateNodeType } from './template-node-type.model'; -import type { TemplateNode } from './template-node.model'; -import type { TextNode } from './text-node.model'; - -export function parseTokens(tokens: Token[]) { - let cursor = 0; - const nodes: TemplateNode[] = []; - - while (cursor < tokens.length) { - const node = parseStandaloneToken(tokens[cursor]!); - nodes.push(node); - cursor += 1; - } - - function parseStandaloneToken(token: Token): TemplateNode { - switch (token.type) { - case TokenType.TEXT: - return parseText(token); - case TokenType.OPEN_INTERPOLATION: - return parseInterpolation(token); - case TokenType.IF_STATEMENT: - return parseIfStatement(token); - case TokenType.FOR_STATEMENT: - return parseForStatement(token); - case TokenType.SWITCH_STATEMENT: - return parseSwitchStatement(token); - case TokenType.SWITCH_CASE_STATEMENT: - throw new TemplateError( - 'Cannot use @case without a @switch statement.', - token.startIndex, - ); - case TokenType.SWITCH_DEFAULT_STATEMENT: - throw new TemplateError( - 'Cannot use @default without a @switch statement.', - token.startIndex, - ); - case TokenType.ELSE_IF_STATEMENT: - throw new TemplateError( - 'Cannot use @elseif without an @if statement.', - token.startIndex, - ); - case TokenType.ELSE_STATEMENT: - throw new TemplateError( - 'Cannot use @else without an @if statement.', - token.startIndex, - ); - default: - throw new TemplateError( - `Unknown token type '${token.type}'.`, - token.startIndex, - ); - } - } - - function parseNestedTokens(): TemplateNode[] { - const nodes: TemplateNode[] = []; - let next = tokens[cursor + 1]; - - while ( - // TODO: Add null check here for `next`. - next.type === TokenType.TEXT || - next.type === TokenType.OPEN_INTERPOLATION || - next.type === TokenType.IF_STATEMENT || - next.type === TokenType.FOR_STATEMENT || - next.type === TokenType.SWITCH_STATEMENT - ) { - cursor += 1; - const node = parseStandaloneToken(next); - nodes.push(node); - next = tokens[cursor + 1]; - } - - return nodes; - } - - function parseText(token: Token): TextNode { - return { - type: TemplateNodeType.TEXT, - text: token.substring, - }; - } - - function parseInterpolation(_token: Token): InterpolationNode { - const expression = need(TokenType.EXPRESSION).substring; - need(TokenType.CLOSE_INTERPOLATION); - - return { - type: TemplateNodeType.INTERPOLATION, - expression, - }; - } - - function parseIfStatement(_token: Token): IfStatementNode { - const branches: (IfBranch | ElseBranch)[] = []; - - const expression = need(TokenType.EXPRESSION).substring; - need(TokenType.OPEN_BLOCK); - const children = parseNestedTokens(); - - branches.push({ type: 'if', expression, children }); - need(TokenType.CLOSE_BLOCK); - - while (expect(TokenType.ELSE_IF_STATEMENT)) { - const expression = need(TokenType.EXPRESSION).substring; - need(TokenType.OPEN_BLOCK).substring; - const children = parseNestedTokens(); - - branches.push({ type: 'else if', expression, children }); - need(TokenType.CLOSE_BLOCK); - } - - if (expect(TokenType.ELSE_STATEMENT)) { - need(TokenType.OPEN_BLOCK); - const children = parseNestedTokens(); - - branches.push({ type: 'else', expression: null, children }); - need(TokenType.CLOSE_BLOCK); - } - - return { - type: TemplateNodeType.IF_STATEMENT, - branches, - }; - } - - function parseForStatement(_token: Token): ForStatementNode { - const expression = need(TokenType.EXPRESSION).substring; - need(TokenType.OPEN_BLOCK); - - const children = parseNestedTokens(); - need(TokenType.CLOSE_BLOCK); - - return { - type: TemplateNodeType.FOR_STATEMENT, - expression, - children, - }; - } - - function parseSwitchStatement(_token: Token): SwitchStatementNode { - const expression = need(TokenType.EXPRESSION).substring; - need(TokenType.OPEN_BLOCK); - - const branches: (CaseBranch | DefaultBranch)[] = []; - - while (expect(TokenType.SWITCH_CASE_STATEMENT)) { - const expression = need(TokenType.EXPRESSION).substring; - need(TokenType.OPEN_BLOCK); - const children = parseNestedTokens(); - - branches.push({ type: 'case', expression, children }); - need(TokenType.CLOSE_BLOCK); - } - - if (expect(TokenType.SWITCH_DEFAULT_STATEMENT)) { - need(TokenType.OPEN_BLOCK); - const children = parseNestedTokens(); - - branches.push({ type: 'default', children }); - need(TokenType.CLOSE_BLOCK); - } - - need(TokenType.CLOSE_BLOCK); - - return { - type: TemplateNodeType.SWITCH_STATEMENT, - expression, - branches, - }; - } - - function need(tokenType: TokenType): Token { - const nextOfType = expect(tokenType); - - if (!nextOfType) { - throw new TemplateError( - `Expected token type '${tokenType}'.`, - tokens[cursor + 1].startIndex, - ); - } - - return nextOfType; - } - - function expect(tokenType: TokenType): Token | null { - const next = tokens[cursor + 1]; - - if (next.type !== tokenType) { - return null; - } - - cursor += 1; - return next; - } - - return nodes; -} diff --git a/packages/client-api/src/template-engine/token-parsing/switch-statement-node.model.ts b/packages/client-api/src/template-engine/token-parsing/switch-statement-node.model.ts deleted file mode 100644 index f5c317f8..00000000 --- a/packages/client-api/src/template-engine/token-parsing/switch-statement-node.model.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TemplateNodeType } from './template-node-type.model'; -import type { TemplateNode } from './template-node.model'; - -export interface CaseBranch { - type: 'case'; - expression: string; - children: TemplateNode[]; -} - -export interface DefaultBranch { - type: 'default'; - children: TemplateNode[]; -} - -export interface SwitchStatementNode { - type: TemplateNodeType.SWITCH_STATEMENT; - expression: string; - branches: (CaseBranch | DefaultBranch)[]; -} diff --git a/packages/client-api/src/template-engine/token-parsing/template-node-type.model.ts b/packages/client-api/src/template-engine/token-parsing/template-node-type.model.ts deleted file mode 100644 index 013900ac..00000000 --- a/packages/client-api/src/template-engine/token-parsing/template-node-type.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum TemplateNodeType { - TEXT = 'TEXT', - INTERPOLATION = 'INTERPOLATION', - IF_STATEMENT = 'IF_STATEMENT', - FOR_STATEMENT = 'FOR_STATEMENT', - SWITCH_STATEMENT = 'SWITCH_STATEMENT', -} diff --git a/packages/client-api/src/template-engine/token-parsing/template-node.model.ts b/packages/client-api/src/template-engine/token-parsing/template-node.model.ts deleted file mode 100644 index ea860a18..00000000 --- a/packages/client-api/src/template-engine/token-parsing/template-node.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SwitchStatementNode } from './switch-statement-node.model'; -import type { ForStatementNode } from './for-statement-node'; -import type { IfStatementNode } from './if-statement-node.model'; -import type { InterpolationNode } from './interpolation-node.model'; -import type { TextNode } from './text-node.model'; - -export type TemplateNode = - | TextNode - | InterpolationNode - | IfStatementNode - | ForStatementNode - | SwitchStatementNode; diff --git a/packages/client-api/src/template-engine/token-parsing/text-node.model.ts b/packages/client-api/src/template-engine/token-parsing/text-node.model.ts deleted file mode 100644 index f0f32a33..00000000 --- a/packages/client-api/src/template-engine/token-parsing/text-node.model.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { TemplateNodeType } from './template-node-type.model'; - -export interface TextNode { - type: TemplateNodeType.TEXT; - text: string; -} diff --git a/packages/client-api/src/template-engine/tokenizing/index.ts b/packages/client-api/src/template-engine/tokenizing/index.ts deleted file mode 100644 index 60b22945..00000000 --- a/packages/client-api/src/template-engine/tokenizing/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './token-type.model'; -export * from './token.model'; -export * from './tokenize-template'; diff --git a/packages/client-api/src/template-engine/tokenizing/token-type.model.ts b/packages/client-api/src/template-engine/tokenizing/token-type.model.ts deleted file mode 100644 index fea9e587..00000000 --- a/packages/client-api/src/template-engine/tokenizing/token-type.model.ts +++ /dev/null @@ -1,40 +0,0 @@ -export enum TokenType { - /** Start of if statement (ie. `@if`). */ - IF_STATEMENT = 'IF_STATEMENT', - - /** Start of else if statement (ie. `@else if`). */ - ELSE_IF_STATEMENT = 'ELSE_IF_STATEMENT', - - /** Start of else statement (ie. `@else`). */ - ELSE_STATEMENT = 'ELSE_STATEMENT', - - /** Start of for statement (ie. `@for`). */ - FOR_STATEMENT = 'FOR_STATEMENT', - - /** Start of switch statement (ie. `@switch`). */ - SWITCH_STATEMENT = 'SWITCH_STATEMENT', - - /** Start of switch case statement (ie. `@case`). */ - SWITCH_CASE_STATEMENT = 'SWITCH_CASE_STATEMENT', - - /** Start of switch default statement (ie. `@default`). */ - SWITCH_DEFAULT_STATEMENT = 'SWITCH_DEFAULT_STATEMENT', - - /** Opening curly brace (ie. `{`) after the start of a statement. */ - OPEN_BLOCK = 'OPEN_BLOCK', - - /** Closing curly brace (ie. `}`) marking the end of a statement. */ - CLOSE_BLOCK = 'CLOSE_BLOCK', - - /** Opening double curly brace (ie. `{{`) of an interpolation tag. */ - OPEN_INTERPOLATION = 'OPEN_INTERPOLATION', - - /** Closing double curly brace (ie. `}}`) of an interpolation tag. */ - CLOSE_INTERPOLATION = 'CLOSE_INTERPOLATION', - - /** Expression to evaluate (within tag start or an interpolation tag. */ - EXPRESSION = 'EXPRESSION', - - /** Ordinary text. */ - TEXT = 'TEXT', -} diff --git a/packages/client-api/src/template-engine/tokenizing/token.model.ts b/packages/client-api/src/template-engine/tokenizing/token.model.ts deleted file mode 100644 index 8f740382..00000000 --- a/packages/client-api/src/template-engine/tokenizing/token.model.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TokenType } from './token-type.model'; - -export interface Token { - type: TokenType; - substring: string; - startIndex: number; - endIndex: number; -} diff --git a/packages/client-api/src/template-engine/tokenizing/tokenize-template.ts b/packages/client-api/src/template-engine/tokenizing/tokenize-template.ts deleted file mode 100644 index 68c60b17..00000000 --- a/packages/client-api/src/template-engine/tokenizing/tokenize-template.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { createStringScanner } from '~/utils'; -import type { Token } from './token.model'; -import { TokenType } from './token-type.model'; -import { TemplateError } from '../shared'; - -export enum TokenizeStateType { - DEFAULT, - IN_STATEMENT_ARGS, - IN_STATEMENT_BLOCK, - IN_INTERPOLATION, - IN_EXPRESSION, -} - -export interface InExpressionState { - type: TokenizeStateType.IN_EXPRESSION; - token?: Token; - closeRegex: RegExp; - activeWrappingSymbol: string | null; -} - -export type TokenizeState = - | { type: TokenizeStateType } - | InExpressionState; - -export function tokenizeTemplate(template: string): Token[] { - // Stack of tokenize states. Last element represents current state. - const stateStack: TokenizeState[] = [ - { type: TokenizeStateType.DEFAULT }, - ]; - - // Tokens within input template. - const tokens: Token[] = []; - - // String scanner for advancing through input template. - const scanner = createStringScanner(template); - - function pushToken(typeOrToken: TokenType | Token) { - const token = - typeof typeOrToken === 'object' - ? typeOrToken - : { type: typeOrToken, ...scanner.latestMatch! }; - - // Skip pushing empty tokens. - if (token.substring) { - tokens.push(token); - } - } - - // Push a tokenize state. - function pushState(typeOrState: TokenizeStateType | TokenizeState) { - const state = - typeof typeOrState === 'object' - ? typeOrState - : { type: typeOrState }; - - stateStack.push(state); - } - - function updateLatestState(state: Partial) { - if (!stateStack.length) { - throw new TemplateError( - 'Could not update latest state while tokenizing template.', - scanner.cursor, - ); - } - - stateStack[stateStack.length - 1] = { - ...stateStack[stateStack.length - 1]!, - ...state, - }; - } - - // Get current tokenize state. - function getState() { - return stateStack[stateStack.length - 1]; - } - - while (!scanner.isEmpty) { - switch (getState()!.type) { - case TokenizeStateType.DEFAULT: - tokenizeDefault(); - break; - case TokenizeStateType.IN_STATEMENT_ARGS: - tokenizeStatementArgs(); - break; - case TokenizeStateType.IN_STATEMENT_BLOCK: - tokenizeStatementBlock(); - break; - case TokenizeStateType.IN_INTERPOLATION: - tokenizeInterpolation(); - break; - case TokenizeStateType.IN_EXPRESSION: - tokenizeExpression(); - break; - } - } - - function tokenizeDefault() { - if (scanner.scan(/\s*@if/)) { - pushToken(TokenType.IF_STATEMENT); - pushState(TokenizeStateType.IN_STATEMENT_ARGS); - } else if (scanner.scan(/\s*@else\s+if/)) { - pushToken(TokenType.ELSE_IF_STATEMENT); - pushState(TokenizeStateType.IN_STATEMENT_ARGS); - } else if (scanner.scan(/\s*@else/)) { - pushToken(TokenType.ELSE_STATEMENT); - pushState(TokenizeStateType.IN_STATEMENT_ARGS); - } else if (scanner.scan(/\s*@for/)) { - pushToken(TokenType.FOR_STATEMENT); - pushState(TokenizeStateType.IN_STATEMENT_ARGS); - } else if (scanner.scan(/\s*@switch/)) { - pushToken(TokenType.SWITCH_STATEMENT); - pushState(TokenizeStateType.IN_STATEMENT_ARGS); - } else if (scanner.scan(/\s*@case/)) { - pushToken(TokenType.SWITCH_CASE_STATEMENT); - pushState(TokenizeStateType.IN_STATEMENT_ARGS); - } else if (scanner.scan(/\s*@default/)) { - pushToken(TokenType.SWITCH_DEFAULT_STATEMENT); - pushState(TokenizeStateType.IN_STATEMENT_ARGS); - } else if (scanner.scan(/(?:[\n\r\v\f\t]+\s*|){{/)) { - // Ignore newlines before interpolation tag. Spaces are not ignored - // for when the interpolation tag is mixed in with text tokens. - pushToken(TokenType.OPEN_INTERPOLATION); - pushState(TokenizeStateType.IN_INTERPOLATION); - } else if (scanner.scanUntil(/[\S\s]+?(?={{|@|\})/)) { - // Search until a close block, the start of a statement, or the start - // of an interpolation tag. In a JavaScript regex, the `.` character - // does not match newline characters, so we instead use [\S\s]. - pushToken({ - type: TokenType.TEXT, - ...scanner.latestMatch!, - }); - } else { - throw new TemplateError('No valid tokens found.', scanner.cursor); - } - } - - function tokenizeStatementArgs() { - if (scanner.scan(/\)?\s+/)) { - // Ignore the closing parenthesis and any following whitespace. - } else if (scanner.scan(/\(/)) { - pushState({ - type: TokenizeStateType.IN_EXPRESSION, - closeRegex: /[\S\s]+?(?=\))/, - activeWrappingSymbol: null, - }); - } else if (scanner.scan(/{\s*/)) { - pushToken(TokenType.OPEN_BLOCK); - stateStack.pop(); - pushState(TokenizeStateType.IN_STATEMENT_BLOCK); - } else { - throw new TemplateError( - 'Missing closing { after statement.', - scanner.cursor, - ); - } - } - - function tokenizeStatementBlock() { - if (scanner.scan(/}\s*/)) { - pushToken(TokenType.CLOSE_BLOCK); - stateStack.pop(); - } else { - tokenizeDefault(); - } - } - - function tokenizeInterpolation() { - if (scanner.scan(/\s+/)) { - // Ignore whitespace within interpolation tag. - } else if (scanner.scan(/}}(?:[\n\r\v\f\t]+\s*|)/)) { - pushToken(TokenType.CLOSE_INTERPOLATION); - stateStack.pop(); - } else if (scanner.scan(/[\S\s]*?/)) { - pushState({ - type: TokenizeStateType.IN_EXPRESSION, - closeRegex: /[\S\s]+?(?=}})/, - activeWrappingSymbol: null, - }); - } else { - throw new TemplateError( - 'Missing closing }} after expression.', - scanner.cursor, - ); - } - } - - function tokenizeExpression() { - const state = getState() as InExpressionState; - - if (scanner.scan(/\s+/)) { - // Ignore whitespace within expression. - } else if (scanner.scan(state.closeRegex)) { - const { startIndex, endIndex, substring } = scanner.latestMatch!; - - // String scanner for finding wrapping symbols within the matched - // substring. The closing symbol should be ignored if wrapped within an - // unclosed string or parenthesis. - const subScanner = createStringScanner(substring); - let activeWrappingSymbol = state.activeWrappingSymbol; - - while (!subScanner.isEmpty) { - const symbolMatch = subScanner.scan(/[\S\s]*?('|`|\(|\)|")/); - - if (!symbolMatch) { - break; - } - - // Get last character of scanned string (either (, ), ', ", or `). - const foundSymbol = symbolMatch.substring.trimEnd().slice(-1); - - activeWrappingSymbol = getActiveWrappingSymbol( - activeWrappingSymbol, - foundSymbol, - ); - } - - // If there's an active wrapping symbol, update the token created thus - // far, and continue scanning. - if (activeWrappingSymbol) { - updateLatestState({ - activeWrappingSymbol, - token: { - type: TokenType.EXPRESSION, - startIndex: state.token?.startIndex ?? startIndex, - endIndex, - substring: (state.token?.substring ?? '') + substring, - }, - }); - return; - } - - pushToken({ - type: TokenType.EXPRESSION, - startIndex: state.token?.startIndex ?? startIndex, - endIndex, - substring: (state.token?.substring ?? '') + substring.trimEnd(), - }); - - stateStack.pop(); - } else { - throw new TemplateError( - 'Missing close symbol after expression.', - scanner.cursor, - ); - } - } - - function getActiveWrappingSymbol( - current: string | null, - matched: string, - ) { - const isOpeningSymbol = matched !== ')'; - - // Set active wrapping symbol to the matched symbol. - if (!current && isOpeningSymbol) { - return matched; - } - - const inverse = current === '(' ? ')' : current; - - // Otherwise, set/clear wrapping symbol depending on whether the inverse - // symbol matches. - return matched === inverse ? null : current; - } - - // If state stack includes more than just the default state, then a - // statement or expression has no closing tag. - if (stateStack.length > 1) { - throw new TemplateError( - 'Missing close symbol after statement or expression.', - scanner.cursor, - ); - } - - return tokens; -} diff --git a/packages/client-api/src/user-config/get-parsed-element-config.ts b/packages/client-api/src/user-config/get-parsed-element-config.ts deleted file mode 100644 index 3da156bc..00000000 --- a/packages/client-api/src/user-config/get-parsed-element-config.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { type Owner, createComputed, runWithOwner } from 'solid-js'; -import { createStore } from 'solid-js/store'; - -import type { ElementContext } from '~/element-context.model'; -import { ElementType } from '~/element-type.model'; -import { TemplateError, getTemplateEngine } from '~/template-engine'; -import { - GroupConfigSchemaP1, - TemplateConfigSchema, - WindowConfigSchemaP1, - parseWithSchema, -} from '~/user-config'; -import type { PickPartial } from '~/utils'; - -export function getParsedElementConfig( - elementContext: PickPartial, - owner: Owner, -) { - const templateEngine = getTemplateEngine(); - - const [parsedConfig, setParsedConfig] = createStore(getParsedConfig()); - - // Update the store on changes to any provider variables. - runWithOwner(owner, () => { - createComputed(() => setParsedConfig(getParsedConfig())); - }); - - /** - * Get updated store value. - */ - function getParsedConfig() { - const config = { - ...(elementContext.rawConfig as Record), - id: elementContext.id, - }; - - const schema = getSchemaForElement(elementContext.type); - - const newConfigEntries = Object.entries(config).map(([key, value]) => { - // If value is not a string, then it can't contain any templating syntax. - if (typeof value !== 'string') { - return [key, value]; - } - - // Run the value through the templating engine. - try { - const rendered = templateEngine.render( - value, - elementContext.providers, - ); - - return [key, rendered]; - } catch (err) { - if (!(err instanceof TemplateError)) { - throw err; - } - - const { message, templateIndex } = err; - - throw new Error( - `Property '${key}' in config isn't valid.\n\n` + - 'Syntax error at:\n' + - `...${value.slice(templateIndex - 30, templateIndex)} << \n\n` + - `⚠️ ${message}`, - ); - } - }); - - // TODO: Add logging for updated config here. - const newConfig = Object.fromEntries(newConfigEntries); - - return parseWithSchema(schema, newConfig); - } - - return parsedConfig; -} - -// TODO: Validate in P1 schemas that `template/` and `group/` keys exist. -function getSchemaForElement(type: ElementType) { - switch (type) { - case ElementType.WINDOW: - return WindowConfigSchemaP1.strip(); - case ElementType.GROUP: - return GroupConfigSchemaP1.strip(); - case ElementType.TEMPLATE: - return TemplateConfigSchema.strip(); - } -} diff --git a/packages/client-api/src/user-config/get-script-manager.ts b/packages/client-api/src/user-config/get-script-manager.ts deleted file mode 100644 index 33019e87..00000000 --- a/packages/client-api/src/user-config/get-script-manager.ts +++ /dev/null @@ -1,92 +0,0 @@ -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(/* @vite-ignore */ 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/get-style-builder.ts b/packages/client-api/src/user-config/get-style-builder.ts deleted file mode 100644 index 7bc09c13..00000000 --- a/packages/client-api/src/user-config/get-style-builder.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { createLogger, toCssSelector } from '~/utils'; - -const logger = createLogger('style-builder'); - -let globalStyles: string | null = null; -let elementStyles: Record = {}; -let styleElement: HTMLStyleElement | null = null; - -/** - * Abstraction over building CSS from user-defined styles. - */ -export function getStyleBuilder() { - function buildGlobalStyles(styles: string) { - logger.debug(`Updating global CSS:`, styles); - - globalStyles = styles; - buildStyles(); - } - - function buildElementStyles(id: string, styles: string) { - // Wrap user-defined styles in a scope. - const scopedStyles = `#${toCssSelector(id)} {\n${styles}}`; - logger.debug(`Updating element '${id}' CSS:\n`, scopedStyles); - - elementStyles[id] = scopedStyles; - buildStyles(); - } - - /** - * Compile user-defined CSS and add it to the DOM. - */ - function buildStyles() { - if (!styleElement) { - styleElement = document.createElement('style'); - styleElement.setAttribute('data-zebar', ''); - document.head.appendChild(styleElement); - } - - const styles = [globalStyles ?? '', ...Object.values(elementStyles)]; - styleElement.innerHTML = styles.join('\n'); - } - - return { - buildGlobalStyles, - buildElementStyles, - }; -} diff --git a/packages/client-api/src/user-config/get-user-config.ts b/packages/client-api/src/user-config/get-user-config.ts deleted file mode 100644 index 49cec245..00000000 --- a/packages/client-api/src/user-config/get-user-config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createSignal } from 'solid-js'; -import { parse } from 'yaml'; - -import { createLogger } from '~/utils'; -import { readConfigFile } from '~/desktop'; - -const logger = createLogger('get-user-config'); - -/** - * User config (if read) as parsed YAML. - */ -const [userConfig, setUserConfig] = createSignal(null); - -/** - * Get user config as parsed YAML. - */ -export async function getUserConfig() { - if (userConfig()) { - return userConfig(); - } - - try { - const configStr = await readConfigFile(); - const configObj = parse(configStr) as unknown; - setUserConfig(configObj); - - logger.debug(`Read config:`, configObj); - - return configObj; - } catch (err) { - throw new Error( - `Problem reading config file. ${(err as Error).message}`, - ); - } -} diff --git a/packages/client-api/src/user-config/global-config.model.ts b/packages/client-api/src/user-config/global-config.model.ts deleted file mode 100644 index e20609ae..00000000 --- a/packages/client-api/src/user-config/global-config.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { BooleanLikeSchema } from './shared'; - -export const GlobalConfigSchema = z - .object({ - enable_devtools: BooleanLikeSchema.default(false), - }) - .partial(); - -export type GlobalConfig = z.infer; diff --git a/packages/client-api/src/user-config/index.ts b/packages/client-api/src/user-config/index.ts index 51522251..d2673b74 100644 --- a/packages/client-api/src/user-config/index.ts +++ b/packages/client-api/src/user-config/index.ts @@ -1,9 +1 @@ -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'; -export * from './parse-with-schema'; -export * from './shared'; -export * from './user-config.model'; -export * from './window'; +export * from './window-config'; diff --git a/packages/client-api/src/user-config/parse-with-schema.ts b/packages/client-api/src/user-config/parse-with-schema.ts deleted file mode 100644 index b9f5e99c..00000000 --- a/packages/client-api/src/user-config/parse-with-schema.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ZodError, type z } from 'zod'; - -/** - * Parse a value with error formatting. - */ -export function parseWithSchema( - schema: T, - value: unknown, -): z.infer { - try { - return schema.parse(value); - } catch (err) { - if (err instanceof ZodError && err.errors.length) { - const [firstError] = err.errors; - const { message, path } = firstError!; - const fullPath = path.join('.'); - - throw new Error( - `Property '${fullPath}' in config isn't valid.\n` + - `⚠️ ${message}`, - ); - } - - throw new Error(`Failed to parse config. ${(err as Error).message}`); - } -} diff --git a/packages/client-api/src/user-config/shared/boolean-like.model.ts b/packages/client-api/src/user-config/shared/boolean-like.model.ts deleted file mode 100644 index b0f97f3f..00000000 --- a/packages/client-api/src/user-config/shared/boolean-like.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { z } from 'zod'; - -export const BooleanLikeSchema = z - .union([z.boolean(), z.literal('true'), z.literal('false')]) - .transform(value => value === true || value === 'true'); diff --git a/packages/client-api/src/user-config/shared/get-child-configs.ts b/packages/client-api/src/user-config/shared/get-child-configs.ts deleted file mode 100644 index e30ecf73..00000000 --- a/packages/client-api/src/user-config/shared/get-child-configs.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ElementConfig } from '~/element-context.model'; -import type { GroupConfig, TemplateConfig } from '../window'; -import type { ElementType } from '~/element-type.model'; - -export interface ChildConfigRef { - type: ElementType; - id: string; - config: TemplateConfig | GroupConfig; -} - -/** - * Get template and group configs within {@link rawConfig}. - */ -export function getChildConfigs(rawConfig: unknown): ChildConfigRef[] { - return Object.entries(rawConfig as ElementConfig).reduce< - ChildConfigRef[] - >((acc, [key, value]) => { - const childKeyRegex = /^(template|group)\/(.+)$/; - const match = key.match(childKeyRegex); - - if (!match) { - return acc; - } - - return [ - ...acc, - { - type: match[1], - id: match[2], - config: value, - } as ChildConfigRef, - ]; - }, []); -} diff --git a/packages/client-api/src/user-config/shared/index.ts b/packages/client-api/src/user-config/shared/index.ts deleted file mode 100644 index 7bb6ea76..00000000 --- a/packages/client-api/src/user-config/shared/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './boolean-like.model'; -export * from './get-child-configs'; -export * from './with-dynamic-key'; diff --git a/packages/client-api/src/user-config/shared/with-dynamic-key.ts b/packages/client-api/src/user-config/shared/with-dynamic-key.ts deleted file mode 100644 index 59a02e5b..00000000 --- a/packages/client-api/src/user-config/shared/with-dynamic-key.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { z } from 'zod'; - -export type WithDynamicKeyOptions< - TKey extends string, - TSub extends z.ZodType, -> = { - isKey: (key: string) => key is TKey; - schema: TSub; -}; - -/** - * Adds a dynamic key to an object schema. - * - * @example - * ```typescript - * const MySchema = withDynamicKey(z.object({}), { - * isKey: (key: string): key is `sub/${string}` => key.startsWith('sub/'), - * schema: SubSchema, - * }); // {} & { x: `sub/${string}`]: SubSchema } - * ``` - */ -export function withDynamicKey< - TObject extends z.AnyZodObject, - const TKey extends string, - TSub extends z.ZodType, ->(schema: TObject, options: WithDynamicKeyOptions) { - // `passthrough` is needed here to allow the dynamic key. The resulting type - // is then narrowed down and validated within `transform`. - return schema.passthrough().transform((val, ctx) => { - const defaultKeys = Object.keys(schema.shape); - - for (const key of Object.keys(val)) { - if (defaultKeys.includes(key)) { - continue; - } - - if (!options.isKey(key)) { - ctx.addIssue({ - code: z.ZodIssueCode.unrecognized_keys, - keys: [key], - path: [key], - }); - } - } - - const dynamicKeys = Object.keys(val).filter(key => options.isKey(key)); - - for (const dynamicKey of dynamicKeys) { - const res = options.schema.safeParse(val[dynamicKey]); - - if (res.success) { - val[dynamicKey] = res.data; - continue; - } - - for (const issue of res.error.issues) { - ctx.addIssue({ - ...issue, - path: [dynamicKey, ...issue.path], - }); - } - } - - return val as z.infer & { - [key in TKey]: z.infer; - }; - }); -} diff --git a/packages/client-api/src/user-config/user-config.model.ts b/packages/client-api/src/user-config/user-config.model.ts deleted file mode 100644 index edd5b6fc..00000000 --- a/packages/client-api/src/user-config/user-config.model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod'; - -import { WindowConfigSchema } from './window'; -import { GlobalConfigSchema } from './global-config.model'; -import { withDynamicKey } from './shared'; -import type { Prettify } from '~/utils'; - -export const UserConfigP1Schema = z.object({ - global: GlobalConfigSchema, -}); - -export type UserConfigP1 = Prettify>; - -// Add `window/**` keys to schema. -export const UserConfigSchema = withDynamicKey(UserConfigP1Schema, { - isKey: (key: string): key is `window/${string}` => - key.startsWith('window/'), - schema: WindowConfigSchema, -}); - -export type UserConfig = Prettify>; diff --git a/packages/client-api/src/user-config/window-config.ts b/packages/client-api/src/user-config/window-config.ts new file mode 100644 index 00000000..2a120393 --- /dev/null +++ b/packages/client-api/src/user-config/window-config.ts @@ -0,0 +1,66 @@ +export interface WindowConfig { + /** + * Whether to show the window above/below all others. + */ + z_order: WindowZOrder; + + /** + * Whether the window should be shown in the taskbar. + */ + shown_in_taskbar: boolean; + + /** + * Whether the window should have resize handles. + */ + resizable: boolean; + + /** + * Whether the window is transparent. + */ + transparent: boolean; + + /** + * Entry point HTML file for the window. + */ + html_path: string; + + /** + * Where to place the window. + */ + placements: WindowPlacement[]; +} + +export enum WindowZOrder { + ALWAYS_ON_BOTTOM = 'always_on_bottom', + ALWAYS_ON_TOP = 'always_on_top', + NORMAL = 'normal', +} + +export interface WindowPlacement { + /** + * The monitor index to place the window on. + */ + monitor_index: 0; + + /** + * TODO: Add description. + */ + position: WindowPosition; + + /** + * TODO: Add description. + */ + offset_x: 20; + + /** + * TODO: Add description. + */ + offset_y: 20; +} + +export enum WindowPosition { + TOP = 'top', + BOTTOM = 'bottom', + LEFT = 'left', + RIGHT = 'right', +} 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 deleted file mode 100644 index f5ff4690..00000000 --- a/packages/client-api/src/user-config/window/base-element-config.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -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. */ -export type BaseElementConfig = Prettify< - z.infer ->; 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 deleted file mode 100644 index bcd6bef2..00000000 --- a/packages/client-api/src/user-config/window/element-events-config.model.ts +++ /dev/null @@ -1,124 +0,0 @@ -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/group-config.model.ts b/packages/client-api/src/user-config/window/group-config.model.ts deleted file mode 100644 index 665f3eca..00000000 --- a/packages/client-api/src/user-config/window/group-config.model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod'; - -import { TemplateConfigSchema } from './template-config.model'; -import { BaseElementConfigSchema } from './base-element-config.model'; -import type { Prettify } from '~/utils'; -import { withDynamicKey } from '../shared'; - -export const GroupConfigSchemaP1 = BaseElementConfigSchema.extend({ - class_names: z.array(z.string()).default(['group']), -}); - -export type GroupConfigP1 = Prettify>; - -// Add `template/**` keys to schema. -export const GroupConfigSchema = withDynamicKey(GroupConfigSchemaP1, { - isKey: (key: string): key is `template/${string}` => - key.startsWith('template/'), - schema: TemplateConfigSchema, -}); - -export type GroupConfig = Prettify>; diff --git a/packages/client-api/src/user-config/window/index.ts b/packages/client-api/src/user-config/window/index.ts deleted file mode 100644 index 937e359a..00000000 --- a/packages/client-api/src/user-config/window/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -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'; -export * from './providers'; -export * from './providers-config.model'; -export * from './template-config.model'; -export * from './window-config.model'; -export * from './z-order.model'; diff --git a/packages/client-api/src/user-config/window/provider-config.model.ts b/packages/client-api/src/user-config/window/provider-config.model.ts deleted file mode 100644 index 7adfbaa0..00000000 --- a/packages/client-api/src/user-config/window/provider-config.model.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod'; - -import type { Prettify } from '~/utils'; -import { - BatteryProviderConfigSchema, - CpuProviderConfigSchema, - DateProviderConfigSchema, - GlazeWmProviderConfigSchema, - HostProviderConfigSchema, - IpProviderConfigSchema, - KomorebiProviderConfigSchema, - MemoryProviderConfigSchema, - MonitorsProviderConfigSchema, - NetworkProviderConfigSchema, - SelfProviderConfigSchema, - UtilProviderConfigSchema, - WeatherProviderConfigSchema, -} from './providers'; - -export const ProviderConfigSchema = z.union([ - BatteryProviderConfigSchema, - CpuProviderConfigSchema, - DateProviderConfigSchema, - GlazeWmProviderConfigSchema, - HostProviderConfigSchema, - IpProviderConfigSchema, - KomorebiProviderConfigSchema, - MemoryProviderConfigSchema, - MonitorsProviderConfigSchema, - NetworkProviderConfigSchema, - SelfProviderConfigSchema, - UtilProviderConfigSchema, - WeatherProviderConfigSchema, -]); - -export type ProviderConfig = Prettify< - z.infer ->; diff --git a/packages/client-api/src/user-config/window/provider-type.model.ts b/packages/client-api/src/user-config/window/provider-type.model.ts deleted file mode 100644 index 6ea9c221..00000000 --- a/packages/client-api/src/user-config/window/provider-type.model.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export enum ProviderType { - BATTERY = 'battery', - CPU = 'cpu', - DATE = 'date', - GLAZEWM = 'glazewm', - HOST = 'host', - IP = 'ip', - KOMOREBI = 'komorebi', - MEMORY = 'memory', - MONITORS = 'monitors', - NETWORK = 'network', - SELF = 'self', - UTIL = 'util', - WEATHER = 'weather', -} - -export const ProviderTypeSchema = z.nativeEnum(ProviderType); diff --git a/packages/client-api/src/user-config/window/providers-config.model.ts b/packages/client-api/src/user-config/window/providers-config.model.ts deleted file mode 100644 index 93edd821..00000000 --- a/packages/client-api/src/user-config/window/providers-config.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from 'zod'; - -import { ProviderConfigSchema } from './provider-config.model'; -import { ProviderTypeSchema } from './provider-type.model'; -import type { Prettify } from '~/utils'; - -export const ProvidersConfigSchema = z - .array( - z.union([ - ProviderConfigSchema, - ProviderTypeSchema.transform(type => - ProviderConfigSchema.parse({ type }), - ), - ]), - ) - .default([]); - -export type ProvidersConfig = Prettify< - z.infer ->; diff --git a/packages/client-api/src/user-config/window/providers/battery-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/battery-provider-config.model.ts deleted file mode 100644 index 4f2851ac..00000000 --- a/packages/client-api/src/user-config/window/providers/battery-provider-config.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const BatteryProviderConfigSchema = z.object({ - type: z.literal(ProviderType.BATTERY), - - refresh_interval: z.coerce.number().default(5 * 1000), -}); - -export type BatteryProviderConfig = z.infer< - typeof BatteryProviderConfigSchema ->; diff --git a/packages/client-api/src/user-config/window/providers/cpu-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/cpu-provider-config.model.ts deleted file mode 100644 index 76fb3f5d..00000000 --- a/packages/client-api/src/user-config/window/providers/cpu-provider-config.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const CpuProviderConfigSchema = z.object({ - type: z.literal(ProviderType.CPU), - - refresh_interval: z.coerce.number().default(5 * 1000), -}); - -export type CpuProviderConfig = z.infer; diff --git a/packages/client-api/src/user-config/window/providers/date-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/date-provider-config.model.ts deleted file mode 100644 index 07e6ed82..00000000 --- a/packages/client-api/src/user-config/window/providers/date-provider-config.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const DateProviderConfigSchema = z.object({ - type: z.literal(ProviderType.DATE), - - refresh_interval: z.coerce.number().default(1000), - - /** - * Either a UTC offset (eg. `UTC+8`) or an IANA timezone (eg. - * `America/New_York`). Affects the output of `toFormat()`. - * - * A full list of available IANA timezones can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). - */ - timezone: z.string().optional(), - - /** - * An ISO-639-1 locale, which is either a 2-letter language code (eg. `en`) or - * 4-letter language + country code (eg. `en-gb`). Affects the output of - * `toFormat()`. - * - * A full list of ISO-639-1 locales can be found [here](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes#Table). - */ - locale: z.string().optional(), -}); - -export type DateProviderConfig = z.infer; diff --git a/packages/client-api/src/user-config/window/providers/glazewm-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/glazewm-provider-config.model.ts deleted file mode 100644 index 1e972ba1..00000000 --- a/packages/client-api/src/user-config/window/providers/glazewm-provider-config.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const GlazeWmProviderConfigSchema = z.object({ - type: z.literal(ProviderType.GLAZEWM), -}); - -export type GlazewmProviderConfig = z.infer< - typeof GlazeWmProviderConfigSchema ->; diff --git a/packages/client-api/src/user-config/window/providers/host-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/host-provider-config.model.ts deleted file mode 100644 index 6a87cace..00000000 --- a/packages/client-api/src/user-config/window/providers/host-provider-config.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const HostProviderConfigSchema = z.object({ - type: z.literal(ProviderType.HOST), - - refresh_interval: z.coerce.number().default(60 * 1000), -}); - -export type HostProviderConfig = z.infer; diff --git a/packages/client-api/src/user-config/window/providers/index.ts b/packages/client-api/src/user-config/window/providers/index.ts deleted file mode 100644 index caca0198..00000000 --- a/packages/client-api/src/user-config/window/providers/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * from './battery-provider-config.model'; -export * from './cpu-provider-config.model'; -export * from './date-provider-config.model'; -export * from './glazewm-provider-config.model'; -export * from './host-provider-config.model'; -export * from './ip-provider-config.model'; -export * from './komorebi-provider-config.model'; -export * from './memory-provider-config.model'; -export * from './monitors-provider-config.model'; -export * from './network-provider-config.model'; -export * from './self-provider-config.model'; -export * from './util-provider-config.model'; -export * from './weather-provider-config.model'; diff --git a/packages/client-api/src/user-config/window/providers/ip-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/ip-provider-config.model.ts deleted file mode 100644 index a1c674ee..00000000 --- a/packages/client-api/src/user-config/window/providers/ip-provider-config.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const IpProviderConfigSchema = z.object({ - type: z.literal(ProviderType.IP), - - refresh_interval: z.coerce.number().default(60 * 60 * 1000), -}); - -export type IpProviderConfig = z.infer; diff --git a/packages/client-api/src/user-config/window/providers/komorebi-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/komorebi-provider-config.model.ts deleted file mode 100644 index 451562af..00000000 --- a/packages/client-api/src/user-config/window/providers/komorebi-provider-config.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const KomorebiProviderConfigSchema = z.object({ - type: z.literal(ProviderType.KOMOREBI), -}); - -export type KomorebiProviderConfig = z.infer< - typeof KomorebiProviderConfigSchema ->; diff --git a/packages/client-api/src/user-config/window/providers/memory-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/memory-provider-config.model.ts deleted file mode 100644 index 93a8035d..00000000 --- a/packages/client-api/src/user-config/window/providers/memory-provider-config.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const MemoryProviderConfigSchema = z.object({ - type: z.literal(ProviderType.MEMORY), - - refresh_interval: z.coerce.number().default(5 * 1000), -}); - -export type MemoryProviderConfig = z.infer< - typeof MemoryProviderConfigSchema ->; diff --git a/packages/client-api/src/user-config/window/providers/monitors-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/monitors-provider-config.model.ts deleted file mode 100644 index 69cf1f53..00000000 --- a/packages/client-api/src/user-config/window/providers/monitors-provider-config.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const MonitorsProviderConfigSchema = z.object({ - type: z.literal(ProviderType.MONITORS), -}); - -export type MonitorsProviderConfig = z.infer< - typeof MonitorsProviderConfigSchema ->; diff --git a/packages/client-api/src/user-config/window/providers/network-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/network-provider-config.model.ts deleted file mode 100644 index 2580630d..00000000 --- a/packages/client-api/src/user-config/window/providers/network-provider-config.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const NetworkProviderConfigSchema = z.object({ - type: z.literal(ProviderType.NETWORK), - - refresh_interval: z.coerce.number().default(5 * 1000), -}); - -export type NetworkProviderConfig = z.infer< - typeof NetworkProviderConfigSchema ->; diff --git a/packages/client-api/src/user-config/window/providers/self-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/self-provider-config.model.ts deleted file mode 100644 index a0386720..00000000 --- a/packages/client-api/src/user-config/window/providers/self-provider-config.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const SelfProviderConfigSchema = z.object({ - type: z.literal(ProviderType.SELF), -}); - -export type SelfProviderConfig = z.infer; diff --git a/packages/client-api/src/user-config/window/providers/util-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/util-provider-config.model.ts deleted file mode 100644 index 6cb4ec3b..00000000 --- a/packages/client-api/src/user-config/window/providers/util-provider-config.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const UtilProviderConfigSchema = z.object({ - type: z.literal(ProviderType.UTIL), -}); - -export type UtilProviderConfig = z.infer; diff --git a/packages/client-api/src/user-config/window/providers/weather-provider-config.model.ts b/packages/client-api/src/user-config/window/providers/weather-provider-config.model.ts deleted file mode 100644 index 35e61ab3..00000000 --- a/packages/client-api/src/user-config/window/providers/weather-provider-config.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from 'zod'; - -import { ProviderType } from '../provider-type.model'; - -export const WeatherProviderConfigSchema = z.object({ - type: z.literal(ProviderType.WEATHER), - - /** - * Latitude to retrieve weather for. If not provided, latitude is instead - * estimated based on public IP. - */ - latitude: z.coerce.number().optional(), - - /** - * Longitude to retrieve weather for. If not provided, longitude is instead - * estimated based on public IP. - */ - longitude: z.coerce.number().optional(), - - /** - * How often this component refreshes in milliseconds. - */ - refresh_interval: z.coerce.number().default(60 * 60 * 1000), -}); - -export type WeatherProviderConfig = z.infer< - typeof WeatherProviderConfigSchema ->; diff --git a/packages/client-api/src/user-config/window/template-config.model.ts b/packages/client-api/src/user-config/window/template-config.model.ts deleted file mode 100644 index ca2b2ee8..00000000 --- a/packages/client-api/src/user-config/window/template-config.model.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from 'zod'; - -import { BaseElementConfigSchema } from './base-element-config.model'; - -export const TemplateConfigSchema = BaseElementConfigSchema.extend({ - class_names: z.array(z.string()).default(['template']), - template: z.string(), -}); - -export type TemplateConfig = z.infer; diff --git a/packages/client-api/src/user-config/window/window-config.model.ts b/packages/client-api/src/user-config/window/window-config.model.ts deleted file mode 100644 index 3d884794..00000000 --- a/packages/client-api/src/user-config/window/window-config.model.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from 'zod'; - -import { GroupConfigSchema } from './group-config.model'; -import { BaseElementConfigSchema } from './base-element-config.model'; -import type { Prettify } from '~/utils'; -import { BooleanLikeSchema, withDynamicKey } from '../shared'; -import { ZOrderSchema } from './z-order.model'; - -export const WindowConfigSchemaP1 = BaseElementConfigSchema.extend({ - class_names: z.array(z.string()).default(['window']), - position_x: z.coerce.number(), - position_y: z.coerce.number(), - width: z.coerce.number().min(1), - height: z.coerce.number().min(1), - z_order: ZOrderSchema, - // TODO: Deprecate in future release in favour of `shown_in_taskbar`. - show_in_taskbar: BooleanLikeSchema.optional(), - shown_in_taskbar: BooleanLikeSchema.optional(), - resizable: BooleanLikeSchema.optional(), - global_styles: z.string().optional(), -}); - -export type WindowConfigP1 = Prettify< - z.infer ->; - -// Add `group/**` keys to schema. -// TODO: Should be able to have `template/` as a child of window config. -export const WindowConfigSchema = withDynamicKey(WindowConfigSchemaP1, { - isKey: (key: string): key is `group/${string}` => - key.startsWith('group/'), - schema: GroupConfigSchema, -}); - -export type WindowConfig = Prettify>; diff --git a/packages/client-api/src/user-config/window/z-order.model.ts b/packages/client-api/src/user-config/window/z-order.model.ts deleted file mode 100644 index 903749b5..00000000 --- a/packages/client-api/src/user-config/window/z-order.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -export const ZOrderSchema = z - .enum(['always_on_top', 'always_on_bottom', 'normal']) - .default('normal'); - -export type ZOrder = z.infer; diff --git a/packages/client-api/src/utils/clsx.ts b/packages/client-api/src/utils/clsx.ts deleted file mode 100644 index 6eed8a5b..00000000 --- a/packages/client-api/src/utils/clsx.ts +++ /dev/null @@ -1,38 +0,0 @@ -type ClassValue = - | string - | string[] - | Record - | null - | undefined; - -/** - * Utility for constructing `class` names conditionally. - * Inspired by `clsx` https://github.com/lukeed/clsx. - */ -export function clsx(...inputs: ClassValue[]): string { - let classString = ''; - - for (const input of inputs) { - if (input === null || input === undefined) { - continue; - } - - if (typeof input === 'string') { - classString += `${input} `; - } - - if (Array.isArray(input)) { - input.forEach(inputPart => (classString += `${inputPart} `)); - } - - if (typeof input === 'object') { - for (const [key, val] of Object.entries(input)) { - if (!!val) { - classString += `${key} `; - } - } - } - } - - return classString; -} diff --git a/packages/client-api/src/utils/convert-bytes.ts b/packages/client-api/src/utils/convert-bytes.ts new file mode 100644 index 00000000..f757b772 --- /dev/null +++ b/packages/client-api/src/utils/convert-bytes.ts @@ -0,0 +1,76 @@ +const bitUnits = ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb']; + +const byteCommonUnits = [ + 'B', + 'KB', + 'MB', + 'GB', + 'TB', + 'PB', + 'EB', + 'ZB', + 'YB', +]; + +const byteIECUnits = [ + 'B', + 'KiB', + 'MiB', + 'GiB', + 'TiB', + 'PiB', + 'EiB', + 'ZiB', + 'YiB', +]; + +export enum DataUnit { + BITS = 'bits', + SI_BYTES = 'si_bytes', + IEC_BYTES = 'iec_bytes', +} + +export function convertBytes( + bytes: number, + decimals: number = 0, + unitType: DataUnit = DataUnit.BITS, +) { + let unitIndex = 1; // Kb/KB/KiB + + if (unitType === DataUnit.BITS) { + bytes *= 8; + return convert(1000, bitUnits, bytes, decimals, unitIndex); + } + + if (unitType === DataUnit.SI_BYTES) { + return convert(1000, byteCommonUnits, bytes, decimals, unitIndex); + } + + if (unitType === DataUnit.IEC_BYTES) { + return convert(1024, byteIECUnits, bytes, decimals, unitIndex); + } + + return 'NoUnit'; +} + +function convert( + k: number, + units: string[], + bytes: number, + decimals: number, + unitIndex: number, +) { + const dm = decimals < 0 ? 0 : decimals; + + if (!+bytes) { + return `${(0.0).toFixed(dm)} ${units[unitIndex]}`; + } + + let i = Math.floor(Math.log(bytes) / Math.log(k)); + + if (i < unitIndex) { + i = unitIndex; + } + + return `${(bytes / Math.pow(k, i)).toFixed(dm)} ${units[i]?.trimStart()}`; +} diff --git a/packages/client-api/src/utils/create-getter-proxy.ts b/packages/client-api/src/utils/create-getter-proxy.ts deleted file mode 100644 index 97bb195b..00000000 --- a/packages/client-api/src/utils/create-getter-proxy.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Wraps a target object and deeply tracks property access. - * - * @param target Object to wrap. - * @param callback Invoked on every property access with an array of keys to - * the accessed value. - */ -export function createGetterProxy( - target: T, - callback: (path: (string | symbol)[]) => void, -): T { - // Proxy cache is used to avoid creating a new proxy when a property is - // accessed repeatedly. - const proxyCache = new WeakMap(); - - function wrap( - target: U, - parentPath: (string | symbol)[], - ): U { - if (proxyCache.has(target)) { - return proxyCache.get(target); - } - - const proxy = new Proxy(target, { - get(target, key, receiver) { - const value = Reflect.get(target, key, receiver); - - // Invoke callback with the path to the accessed key. - const path = [...parentPath, key]; - callback(path); - - if (typeof value === 'object' && value !== null) { - return wrap(value, path); - } - - return value; - }, - }); - - proxyCache.set(proxy, target); - return proxy; - } - - return wrap(target, []); -} diff --git a/packages/client-api/src/utils/create-shared-signal.ts b/packages/client-api/src/utils/create-shared-signal.ts deleted file mode 100644 index 501c2e25..00000000 --- a/packages/client-api/src/utils/create-shared-signal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type Signal, createSignal } from 'solid-js'; - -const cache: Record> = {}; - -export function createSharedSignal(key: string, value: T): Signal { - if (cache[key]) { - return cache[key] as Signal; - } - - return (cache[key] = createSignal(value)); -} diff --git a/packages/client-api/src/utils/create-string-scanner.ts b/packages/client-api/src/utils/create-string-scanner.ts deleted file mode 100644 index 28bb4315..00000000 --- a/packages/client-api/src/utils/create-string-scanner.ts +++ /dev/null @@ -1,65 +0,0 @@ -export interface ScannerMatch { - substring: string; - endIndex: number; - startIndex: number; -} - -/** - * Utility for advancing through an input string via regex. - */ -export function createStringScanner(input: string) { - let cursor = 0; - let remainder = input; - let latestMatch: ScannerMatch | null = null; - - // Set `latestMatch` and advance the cursor accordingly. - function setLatestMatch(matchIndex: number, matchLength: number) { - const originalCursor = cursor; - remainder = remainder.substring(matchIndex + matchLength); - cursor += matchIndex + matchLength; - - return (latestMatch = { - substring: input.substring(originalCursor, cursor), - endIndex: cursor, - startIndex: originalCursor, - }); - } - - // If the regex matches at the *current* cursor position, set latest match - // and advance the cursor. - function scan(regex: RegExp): ScannerMatch | null { - const match = regex.exec(remainder); - - return match?.index !== 0 - ? null - : setLatestMatch(match.index, match[0].length); - } - - // If the regex matches at any of the remaining input, set latest match and - // advance the cursor. If there are no matches, advance the cursor to end of - // input. - function scanUntil(regex: RegExp): ScannerMatch { - const match = regex.exec(remainder); - - return match - ? setLatestMatch(match.index, match[0].length) - : setLatestMatch(0, remainder.length); - } - - return { - get cursor() { - return cursor; - }, - get remainder() { - return remainder; - }, - get latestMatch() { - return latestMatch; - }, - get isEmpty() { - return remainder === ''; - }, - scan, - scanUntil, - }; -} diff --git a/packages/client-api/src/utils/deferred.ts b/packages/client-api/src/utils/deferred.ts new file mode 100644 index 00000000..b75fd3fd --- /dev/null +++ b/packages/client-api/src/utils/deferred.ts @@ -0,0 +1,22 @@ +/** + * Utility for creating a promise that can be resolved and rejected outside + * the promise callback. + * + * @example + * ```ts + * const deferred = new Deferred(); + * deferred.resolve(42); + * ``` + */ +export class Deferred { + promise: Promise; + resolve!: (val: T | PromiseLike) => void; + reject!: (err: any) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} diff --git a/packages/client-api/src/utils/index.ts b/packages/client-api/src/utils/index.ts index 85438cd5..7f41fa57 100644 --- a/packages/client-api/src/utils/index.ts +++ b/packages/client-api/src/utils/index.ts @@ -1,9 +1,5 @@ -export * from './types/pick-partial'; -export * from './types/prettify'; -export * from './clsx'; -export * from './create-getter-proxy'; +export * from './convert-bytes'; export * from './create-logger'; -export * from './create-string-scanner'; +export * from './deferred'; export * from './get-coordinate-distance'; export * from './simple-hash'; -export * from './to-css-selector'; diff --git a/packages/client-api/src/utils/to-css-selector.ts b/packages/client-api/src/utils/to-css-selector.ts deleted file mode 100644 index 20490cd3..00000000 --- a/packages/client-api/src/utils/to-css-selector.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Convert a string to a valid CSS selector. - */ -export function toCssSelector(input: string): string { - // Replace non-alphanumeric characters with hyphens. - const sanitizedInput = input.replace(/[^a-zA-Z0-9]/g, '-'); - - // Ensure the selector doesn't start with a number. - return /^\d/.test(sanitizedInput) - ? `_${sanitizedInput}` - : sanitizedInput; -} diff --git a/packages/client-api/src/utils/types/pick-partial.ts b/packages/client-api/src/utils/types/pick-partial.ts deleted file mode 100644 index e33d5a80..00000000 --- a/packages/client-api/src/utils/types/pick-partial.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type PickPartial = Omit & - Partial>; diff --git a/packages/client-api/src/utils/types/prettify.ts b/packages/client-api/src/utils/types/prettify.ts deleted file mode 100644 index 8d9ac734..00000000 --- a/packages/client-api/src/utils/types/prettify.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Utility type for expanding a type to include all its properties. - * Ref: https://youtube.com/shorts/2lCCKiWGlC0 - */ -export type Prettify = { - [K in keyof T]: T[K]; -} & {}; diff --git a/packages/client-api/src/zebar-context.model.ts b/packages/client-api/src/zebar-context.model.ts new file mode 100644 index 00000000..d88ee37f --- /dev/null +++ b/packages/client-api/src/zebar-context.model.ts @@ -0,0 +1,109 @@ +import { Window as TauriWindow } from '@tauri-apps/api/window'; + +import type { WindowConfig, WindowZOrder } from '~/user-config'; +import type { + ProviderConfig, + ProviderGroup, + ProviderGroupConfig, + ProviderMap, +} from './providers'; + +export interface ZebarContext { + currentWindow: ZebarWindow; + + allWindows: ZebarWindow; + + currentMonitor: Monitor; + + allMonitors: Monitor; + + /** + * Opens a new window by a relative path to its config file. + */ + openWindow( + configPath: string, + args?: Record, + ): Promise; + + /** + * Creates an instance of a provider. Alternatively, multiple + * providers can be created using {@link createProviderGroup}. + * + * Waits until the provider has emitted either its first output or first + * error. The provider will continue to output until its `stop` function is + * called. + * + * @throws If the provider config is invalid. *Does not throw* if the + * provider's first emission is an error. + */ + createProvider( + providerConfig: T, + ): Promise; + + /** + * Creates multiple provider instances at once. Alternatively, a single + * provider can be created using {@link createProvider}. + */ + createProviderGroup( + configMap: T, + ): Promise>; +} + +export interface ZebarWindow { + /** + * Unique identifier for the window. + */ + windowId: string; + + /** + * Parsed window config. + */ + config: WindowConfig; + + /** + * Absolute path to the window's config file. + */ + configPath: string; + + /** + * Tauri window instance. + */ + tauri: TauriWindow; + + /** + * Sets the z-order of the window. + */ + setZOrder(zOrder: WindowZOrder): Promise; +} + +export interface Monitor { + /** + * Human-readable name of the monitor. + */ + name: string | null; + + /** + * Width of monitor in physical pixels. + */ + width: number; + + /** + * Height of monitor in physical pixels. + */ + height: number; + + /** + * X-coordinate of monitor in physical pixels. + */ + x: number; + + /** + * Y-coordinate of monitor in physical pixels. + */ + y: number; + + /** + * Scale factor to map physical pixels to logical pixels. + */ + scaleFactor: number; +} diff --git a/packages/client-api/tsup.config.ts b/packages/client-api/tsup.config.ts deleted file mode 100644 index 88706395..00000000 --- a/packages/client-api/tsup.config.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { defineConfig } from 'tsup'; -import * as preset from 'tsup-preset-solid'; - -const preset_options: preset.PresetOptions = { - // array or single object - entries: [ - // default entry (index) - { - // entries with '.tsx' extension will have `solid` export condition generated - entry: 'src/index.ts', - }, - ], - // Set to `true` to remove all `console.*` calls and `debugger` statements in prod builds - drop_console: false, - // Set to `true` to generate a CommonJS build alongside ESM - // cjs: true, -}; - -const CI = - process.env['CI'] === 'true' || - process.env['GITHUB_ACTIONS'] === 'true' || - process.env['CI'] === '"1"' || - process.env['GITHUB_ACTIONS'] === '"1"'; - -export default defineConfig(config => { - const watching = !!config.watch; - - const parsed_options = preset.parsePresetOptions( - preset_options, - watching, - ); - - if (!watching && !CI) { - const package_fields = preset.generatePackageExports(parsed_options); - - console.log( - `package.json: \n\n${JSON.stringify(package_fields, null, 2)}\n\n`, - ); - - // will update ./package.json with the correct export fields - preset.writePackageJson(package_fields); - } - - return preset.generateTsupOptions(parsed_options); -}); diff --git a/packages/client/README.md b/packages/client/README.md deleted file mode 100644 index 434f7bb9..00000000 --- a/packages/client/README.md +++ /dev/null @@ -1,34 +0,0 @@ -## Usage - -Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. - -This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. - -```bash -$ npm install # or pnpm install or yarn install -``` - -### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) - -## Available Scripts - -In the project directory, you can run: - -### `npm dev` or `npm start` - -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.
- -### `npm run build` - -Builds the app for production to the `dist` folder.
-It correctly bundles Solid in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! - -## Deployment - -You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/packages/client/package.json b/packages/client/package.json deleted file mode 100644 index 7a6ef49f..00000000 --- a/packages/client/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@zebar/client", - "version": "0.0.0", - "description": "", - "scripts": { - "build": "vite build --mode production", - "dev": "npm run wait:client-api && vite --mode development", - "wait:client-api": "wait-on ../client-api/dist/index.d.ts" - }, - "dependencies": { - "morphdom": "2.7.4", - "solid-js": "1.8.14", - "zebar": "workspace:*" - }, - "devDependencies": { - "@types/node": "20.11.17", - "typescript": "5.3.3", - "vite": "5.1.1", - "vite-plugin-checker": "0.6.4", - "vite-plugin-solid": "2.9.1", - "wait-on": "7.2.0" - } -} diff --git a/packages/client/src/app/child-element.component.tsx b/packages/client/src/app/child-element.component.tsx deleted file mode 100644 index 9f05795c..00000000 --- a/packages/client/src/app/child-element.component.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Match, Show, Switch, createSignal } from 'solid-js'; -import { type ElementContext, ElementType } from 'zebar'; - -import { TemplateElement } from './template-element.component'; -import { GroupElement } from './group-element.component'; - -export interface ChildElementProps { - childId: string; - parentContext: ElementContext; -} - -export function ChildElement(props: ChildElementProps) { - const [childContext, setChildContext] = - createSignal(null); - - props.parentContext - .initChildElement(props.childId) - .then(setChildContext); - - return ( - - {context => ( - - - - - - - - - )} - - ); -} diff --git a/packages/client/src/app/group-element.component.tsx b/packages/client/src/app/group-element.component.tsx deleted file mode 100644 index 54a74dc5..00000000 --- a/packages/client/src/app/group-element.component.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Index } from 'solid-js'; -import { - type ElementContext, - getChildConfigs, - toCssSelector, -} from 'zebar'; - -import { ChildElement } from './child-element.component'; - -export interface GroupElementProps { - context: ElementContext; -} - -export function GroupElement(props: GroupElementProps) { - const config = props.context.parsedConfig; - const rawConfig = props.context.rawConfig; - - return ( -

- - {childConfig => ( - - )} - -
- ); -} diff --git a/packages/client/src/app/template-element.component.ts b/packages/client/src/app/template-element.component.ts deleted file mode 100644 index 4af2cd04..00000000 --- a/packages/client/src/app/template-element.component.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createEffect, onCleanup, onMount } from 'solid-js'; -import { - type ElementContext, - createLogger, - toCssSelector, - getScriptManager, -} from 'zebar'; -import morphdom from 'morphdom'; - -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[] = []; - - createEffect(() => { - // Subsequent template updates after the initial render - - // Since templates do not include the root template element, - // copy the existing one without its children. - const templateRoot = element.cloneNode(false) as Element; - - // Insert the template into the cloned root element - templateRoot.innerHTML = (config as any).template; - - try { - // Reconcile the DOM with the updated template - // @ts-ignore - TODO: fix config.template type - morphdom(element, templateRoot, { - // Don't morph fromNode or toNode, only their children - childrenOnly: true, - }); - } catch (error) { - // TODO - add error handling for reconciliation here - logger.error( - `Failed to reconciliate ${props.context.id} template:`, - error, - ); - } - - updateEventListeners(); - }); - - onMount(() => { - logger.debug('Mounted'); - try { - // Initial render, set innerHTML to the template - // @ts-ignore - TODO: fix config.template type - element.innerHTML = config.template; - } catch (error) { - logger.error( - `Initial render of ${[props.context.id]} failed:`, - error, - ); - } - }); - onCleanup(() => logger.debug('Cleanup')); - - function createRootElement() { - const element = document.createElement('div'); - element.className = config.class_names.join(' '); - element.id = toCssSelector(config.id); - 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 selectorElements = eventConfig.selector - ? Array.from(element.querySelectorAll(eventConfig.selector)) - : [element]; - - for (const selectorElement of selectorElements) { - if (selectorElement) { - selectorElement.addEventListener( - eventConfig.type, - eventCallback, - ); - - listeners.push({ - eventType: eventConfig.type, - eventCallback, - selectorElement, - }); - } - } - }); - } - - return element; -} diff --git a/packages/client/src/app/window-element.component.tsx b/packages/client/src/app/window-element.component.tsx deleted file mode 100644 index 4d5eca76..00000000 --- a/packages/client/src/app/window-element.component.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Index, Show, createSignal } from 'solid-js'; -import { - type WindowContext, - getChildConfigs, - initWindow, - toCssSelector, -} from 'zebar'; - -import { ChildElement } from './child-element.component'; - -export function WindowElement() { - const [context, setContext] = createSignal(null); - - initWindow(context => setContext(context)); - - return ( - - {context => ( -
- - {childConfig => ( - - )} - -
- )} -
- ); -} diff --git a/packages/client/src/index.css b/packages/client/src/index.css deleted file mode 100644 index 7fad71f3..00000000 --- a/packages/client/src/index.css +++ /dev/null @@ -1,26 +0,0 @@ -html { - box-sizing: border-box; -} - -#zebar { - height: 100vh; - width: 100vw; -} - -/** - * Set default `box-sizing` for all elements to border-box. - */ -*, -*:before, -*:after { - box-sizing: inherit; -} - -/** - * Disable overscroll. - */ -body { - overflow: hidden; - overscroll-behavior-y: none; - overscroll-behavior-x: none; -} diff --git a/packages/client/src/index.tsx b/packages/client/src/index.tsx deleted file mode 100644 index e254fc82..00000000 --- a/packages/client/src/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/* @refresh reload */ -import { render } from 'solid-js/web'; - -import './normalize.css'; -import './index.css'; -import { WindowElement } from './app/window-element.component'; - -const root = document.getElementById('zebar'); - -if (import.meta.env.DEV && !(root instanceof HTMLElement)) { - throw new Error('Root element not found.'); -} - -render(() => , root!); diff --git a/packages/client/src/normalize.css b/packages/client/src/normalize.css deleted file mode 100644 index 2768db43..00000000 --- a/packages/client/src/normalize.css +++ /dev/null @@ -1,351 +0,0 @@ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ - -/* Document - ========================================================================== */ - -/** - * 1. Correct the line height in all browsers. - * 2. Prevent adjustments of font size after orientation changes in iOS. - */ - -html { - line-height: 1.15; /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ -} - -/* Sections - ========================================================================== */ - -/** - * Remove the margin in all browsers. - */ - -body { - margin: 0; -} - -/** - * Render the `main` element consistently in IE. - */ - -main { - display: block; -} - -/** - * Correct the font size and margin on `h1` elements within `section` and - * `article` contexts in Chrome, Firefox, and Safari. - */ - -h1 { - font-size: 2em; - margin: 0.67em 0; -} - -/* Grouping content - ========================================================================== */ - -/** - * 1. Add the correct box sizing in Firefox. - * 2. Show the overflow in Edge and IE. - */ - -hr { - box-sizing: content-box; /* 1 */ - height: 0; /* 1 */ - overflow: visible; /* 2 */ -} - -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ - -pre { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} - -/* Text-level semantics - ========================================================================== */ - -/** - * Remove the gray background on active links in IE 10. - */ - -a { - background-color: transparent; -} - -/** - * 1. Remove the bottom border in Chrome 57- - * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. - */ - -abbr[title] { - border-bottom: none; /* 1 */ - text-decoration: underline; /* 2 */ - text-decoration: underline dotted; /* 2 */ -} - -/** - * Add the correct font weight in Chrome, Edge, and Safari. - */ - -b, -strong { - font-weight: bolder; -} - -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ - -code, -kbd, -samp { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} - -/** - * Add the correct font size in all browsers. - */ - -small { - font-size: 80%; -} - -/** - * Prevent `sub` and `sup` elements from affecting the line height in - * all browsers. - */ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -/* Embedded content - ========================================================================== */ - -/** - * Remove the border on images inside links in IE 10. - */ - -img { - border-style: none; -} - -/* Forms - ========================================================================== */ - -/** - * 1. Change the font styles in all browsers. - * 2. Remove the margin in Firefox and Safari. - */ - -button, -input, -optgroup, -select, -textarea { - font-family: inherit; /* 1 */ - font-size: 100%; /* 1 */ - line-height: 1.15; /* 1 */ - margin: 0; /* 2 */ -} - -/** - * Show the overflow in IE. - * 1. Show the overflow in Edge. - */ - -button, -input { - /* 1 */ - overflow: visible; -} - -/** - * Remove the inheritance of text transform in Edge, Firefox, and IE. - * 1. Remove the inheritance of text transform in Firefox. - */ - -button, -select { - /* 1 */ - text-transform: none; -} - -/** - * Correct the inability to style clickable types in iOS and Safari. - */ - -button, -[type='button'], -[type='reset'], -[type='submit'] { - -webkit-appearance: button; -} - -/** - * Remove the inner border and padding in Firefox. - */ - -button::-moz-focus-inner, -[type='button']::-moz-focus-inner, -[type='reset']::-moz-focus-inner, -[type='submit']::-moz-focus-inner { - border-style: none; - padding: 0; -} - -/** - * Restore the focus styles unset by the previous rule. - */ - -button:-moz-focusring, -[type='button']:-moz-focusring, -[type='reset']:-moz-focusring, -[type='submit']:-moz-focusring { - outline: 1px dotted ButtonText; -} - -/** - * Correct the padding in Firefox. - */ - -fieldset { - padding: 0.35em 0.75em 0.625em; -} - -/** - * 1. Correct the text wrapping in Edge and IE. - * 2. Correct the color inheritance from `fieldset` elements in IE. - * 3. Remove the padding so developers are not caught out when they zero out - * `fieldset` elements in all browsers. - */ - -legend { - box-sizing: border-box; /* 1 */ - color: inherit; /* 2 */ - display: table; /* 1 */ - max-width: 100%; /* 1 */ - padding: 0; /* 3 */ - white-space: normal; /* 1 */ -} - -/** - * Add the correct vertical alignment in Chrome, Firefox, and Opera. - */ - -progress { - vertical-align: baseline; -} - -/** - * Remove the default vertical scrollbar in IE 10+. - */ - -textarea { - overflow: auto; -} - -/** - * 1. Add the correct box sizing in IE 10. - * 2. Remove the padding in IE 10. - */ - -[type='checkbox'], -[type='radio'] { - box-sizing: border-box; /* 1 */ - padding: 0; /* 2 */ -} - -/** - * Correct the cursor style of increment and decrement buttons in Chrome. - */ - -[type='number']::-webkit-inner-spin-button, -[type='number']::-webkit-outer-spin-button { - height: auto; -} - -/** - * 1. Correct the odd appearance in Chrome and Safari. - * 2. Correct the outline style in Safari. - */ - -[type='search'] { - -webkit-appearance: textfield; /* 1 */ - outline-offset: -2px; /* 2 */ -} - -/** - * Remove the inner padding in Chrome and Safari on macOS. - */ - -[type='search']::-webkit-search-decoration { - -webkit-appearance: none; -} - -/** - * 1. Correct the inability to style clickable types in iOS and Safari. - * 2. Change font properties to `inherit` in Safari. - */ - -::-webkit-file-upload-button { - -webkit-appearance: button; /* 1 */ - font: inherit; /* 2 */ -} - -/* Interactive - ========================================================================== */ - -/* - * Add the correct display in Edge, IE 10+, and Firefox. - */ - -details { - display: block; -} - -/* - * Add the correct display in all browsers. - */ - -summary { - display: list-item; -} - -/* Misc - ========================================================================== */ - -/** - * Add the correct display in IE 10+. - */ - -template { - display: none; -} - -/** - * Add the correct display in IE 10. - */ - -[hidden] { - display: none; -} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json deleted file mode 100644 index 23a25d53..00000000 --- a/packages/client/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "@glzr/style-guide/tsconfig/solidjs-app", - "compilerOptions": { - "outDir": "dist", - "types": ["vite/client"], - "paths": { - "~/*": ["./src/app/*"] - } - }, - "include": ["src"] -} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts deleted file mode 100644 index 924738c2..00000000 --- a/packages/client/vite.config.ts +++ /dev/null @@ -1,37 +0,0 @@ -import path from 'path'; -import { defineConfig } from 'vite'; -import tsChecker from 'vite-plugin-checker'; -import solidPlugin from 'vite-plugin-solid'; - -export default defineConfig({ - plugins: [solidPlugin(), tsChecker({ typescript: true })], - // Prevent vite from obscuring Rust errors. - clearScreen: false, - // Tauri expects a fixed port. Fail if that port is not available. - server: { - port: 4200, - strictPort: true, - }, - // Allow use of `TAURI_DEBUG` and other env variables. - // Ref: https://tauri.studio/v1/api/config#buildconfig.beforedevcommand - envPrefix: ['VITE_', 'TAURI_'], - build: { - // Tauri supports ES2021. - target: - process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', - // Don't minify for debug builds. - minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, - // Produce sourcemaps for debug builds. - sourcemap: !!process.env.TAURI_DEBUG, - }, - resolve: { - alias: { - '~': path.resolve(__dirname, './src/app'), - }, - }, - css: { - modules: { - localsConvention: 'dashes', - }, - }, -}); diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 668c3ab9..b8c37ab0 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -39,7 +39,7 @@ regex = "1" [target.'cfg(target_os = "windows")'.dependencies] komorebi-client = { git = "https://github.com/LGUG2Z/komorebi", tag = "v0.1.28" } -windows = { version = "0.57", features = [] } +windows = { version = "0.57", features = ["Win32_System_Console"] } [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.25" diff --git a/packages/desktop/capabilities/main.json b/packages/desktop/capabilities/main.json index 447cb5d6..2ce37650 100644 --- a/packages/desktop/capabilities/main.json +++ b/packages/desktop/capabilities/main.json @@ -1,8 +1,11 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["*-*"], + "description": "Default capabilities", + "windows": ["*"], + "remote": { + "urls": ["http://asset.localhost"] + }, "permissions": [ "app:default", "dialog:allow-message", @@ -13,6 +16,7 @@ "window:allow-center", "window:allow-close", "window:allow-hide", + "window:allow-show", "window:allow-maximize", "window:allow-minimize", "window:allow-set-skip-taskbar", @@ -20,8 +24,6 @@ "window:allow-set-always-on-top", "window:allow-set-resizable", "window:allow-set-position", - "window:allow-set-size", - "shell:allow-open", - "tray:default" + "window:allow-set-size" ] } diff --git a/packages/desktop/installer.wxs b/packages/desktop/installer.wxs index 34b5d35f..b6fc9940 100644 --- a/packages/desktop/installer.wxs +++ b/packages/desktop/installer.wxs @@ -71,9 +71,6 @@ - - - @@ -123,19 +120,6 @@ - - - - - ADD_GLAZEWM_STARTER = 1 - - - - - - - - @@ -296,8 +280,6 @@ {{/if}} - - '. -# -# Docs regarding window: https://some-future-docs-link.com -window/bar: - providers: ['self'] - # Width of the window in physical pixels. - width: '{{ self.args.MONITOR_WIDTH }}' - # Height of the window in physical pixels. - height: '40' - # X-position of the window in physical pixels. - position_x: '{{ self.args.MONITOR_X }}' - # Y-position of the window in physical pixels. - position_y: '{{ self.args.MONITOR_Y }}' - # Whether to show the window above/below all others. - # Allowed values: 'always_on_top', 'always_on_bottom', 'normal'. - z_order: 'normal' - # Whether the window should be shown in the taskbar. - shown_in_taskbar: false - # Whether the window should have resize handles. - resizable: false - # Styles to apply globally within the window. For example, we can use - # this to import the Nerdfonts icon font. Ref https://www.nerdfonts.com/cheat-sheet - # for a cheatsheet of available Nerdfonts icons. - global_styles: | - @import "https://www.nerdfonts.com/assets/css/webfont.css"; - # CSS 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; - align-items: center; - height: 100%; - color: rgb(255 255 255 / 90%); - font-family: ui-monospace, monospace; - font-size: 12px; - padding: 4px 24px; - border-bottom: 1px solid rgb(255 255 255 / 5%);; - background: linear-gradient(rgb(0 0 0 / 90%), rgb(5 2 20 / 85%)); - - i { - color: rgb(115 130 175 / 95%); - margin-right: 7px; - } - - group/left: - styles: | - display: flex; - align-items: center; - - template/logo: - styles: | - margin-right: 20px; - template: | - - - template/glazewm_workspaces: - styles: | - display: flex; - align-items: center; - - .workspace { - background: rgb(255 255 255 / 5%); - margin-right: 4px; - padding: 4px 8px; - color: rgb(255 255 255 / 90%); - border: none; - border-radius: 2px; - cursor: pointer; - - &.displayed { - background: rgb(255 255 255 / 15%); - } - - &.focused, - &:hover { - background: rgb(75 115 255 / 50%); - } - } - providers: ['glazewm'] - events: - - type: 'click' - fn_path: 'script.js#focusWorkspace' - selector: '.workspace' - template: | - @for (workspace of glazewm.currentWorkspaces) { - - } - - group/center: - styles: | - justify-self: center; - - template/clock: - providers: ['date'] - # Available date tokens: https://moment.github.io/luxon/#/formatting?id=table-of-tokens - template: | - {{ date.toFormat(date.now, 'EEE d MMM t') }} - - group/right: - styles: | - justify-self: end; - display: flex; - align-items: center; - - .template { - margin-left: 20px; - } - - template/glazewm_other: - providers: ['glazewm'] - styles: | - .binding-mode, - .tiling-direction { - background: rgb(255 255 255 / 15%); - color: rgb(255 255 255 / 90%); - border-radius: 2px; - padding: 4px 6px; - margin: 0; - } - - .tiling-direction { - border: 0; - cursor: pointer; - } - - events: - - type: 'click' - fn_path: 'script.js#toggleTilingDirection' - selector: '.tiling-direction' - template: | - @for (bindingMode of glazewm.bindingModes) { - - {{ bindingMode.displayName ?? bindingMode.name }} - - } - - @if (glazewm.tilingDirection === 'horizontal') { - - } @else { - - } - - template/network: - providers: ['network'] - template: | - - @if (network.defaultInterface?.type === 'ethernet') { - - } @else if (network.defaultInterface?.type === 'wifi') { - @if (network.defaultGateway?.signalStrength >= 80) {} - @else if (network.defaultGateway?.signalStrength >= 65) {} - @else if (network.defaultGateway?.signalStrength >= 40) {} - @else if (network.defaultGateway?.signalStrength >= 25) {} - @else {} - {{ network.defaultGateway?.ssid }} - } @else { - - } - - template/memory: - providers: ['memory'] - template: | - - {{ Math.round(memory.usage) }}% - - template/cpu: - providers: ['cpu'] - styles: | - .high-usage { - color: #900029; - } - template: | - - - - @if (cpu.usage > 85) { - {{ Math.round(cpu.usage) }}% - } @else { - {{ Math.round(cpu.usage) }}% - } - - template/battery: - providers: ['battery'] - styles: | - position: relative; - - .charging-icon { - position: absolute; - font-size: 11px; - left: 7px; - top: 2px; - } - template: | - - @if (battery.isCharging) {} - - - @if (battery.chargePercent > 90) {} - @else if (battery.chargePercent > 70) {} - @else if (battery.chargePercent > 40) {} - @else if (battery.chargePercent > 20) {} - @else {} - - {{ Math.round(battery.chargePercent) }}% - - template/weather: - providers: ['weather'] - template: | - @switch (weather.status) { - @case ('clear_day') {} - @case ('clear_night') {} - @case ('cloudy_day') {} - @case ('cloudy_night') {} - @case ('light_rain_day') {} - @case ('light_rain_night') {} - @case ('heavy_rain_day') {} - @case ('heavy_rain_night') {} - @case ('snow_day') {} - @case ('snow_night') {} - @case ('thunder_day') {} - @case ('thunder_night') {} - } - {{ weather.celsiusTemp }}° diff --git a/packages/desktop/resources/script.js b/packages/desktop/resources/script.js deleted file mode 100644 index 3af809c0..00000000 --- a/packages/desktop/resources/script.js +++ /dev/null @@ -1,10 +0,0 @@ -export function focusWorkspace(event, context) { - console.log('Focus button clicked!', event, context); - const id = event.target.id; - context.providers.glazewm.focusWorkspace(id); -} - -export function toggleTilingDirection(event, context) { - console.log('Tiling direction toggled!', event, context); - context.providers.glazewm.toggleTilingDirection(); -} diff --git a/packages/desktop/resources/start.bat b/packages/desktop/resources/start.bat deleted file mode 100644 index 64bf55eb..00000000 --- a/packages/desktop/resources/start.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -@REM Start hidden powershell script, which runs `zebar open bar --args ...` for every monitor. -powershell -WindowStyle hidden -Command ^ - $monitors = zebar monitors; ^ - foreach ($monitor in $monitors) { Start-Process -WindowStyle Hidden -FilePath \"zebar\" -ArgumentList \"open bar --args $monitor\" }; diff --git a/packages/desktop/resources/start.sh b/packages/desktop/resources/start.sh deleted file mode 100644 index ec6e6642..00000000 --- a/packages/desktop/resources/start.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash -# Run `zebar open bar --args ...` for every monitor. -zebar monitors --print0 | xargs -0 -P 99 -I % sh -c 'zebar open bar --args %' diff --git a/packages/desktop/src/cli.rs b/packages/desktop/src/cli.rs index f63ba554..aa0eec4f 100644 --- a/packages/desktop/src/cli.rs +++ b/packages/desktop/src/cli.rs @@ -1,37 +1,71 @@ -use std::process; +use std::{path::PathBuf, process}; use clap::{Args, Parser, Subcommand}; const VERSION: &'static str = env!("VERSION_NUMBER"); -#[derive(Parser, Debug)] -#[clap(author, version = VERSION, about, long_about = None, arg_required_else_help = true)] +#[derive(Clone, Debug, Parser)] +#[clap(author, version = VERSION, about, long_about = None)] pub struct Cli { #[command(subcommand)] - pub command: CliCommand, + command: Option, } -#[derive(Subcommand, Debug)] +impl Cli { + pub fn command(&self) -> CliCommand { + self.command.clone().unwrap_or(CliCommand::Empty) + } +} + +#[derive(Clone, Debug, PartialEq, Subcommand)] pub enum CliCommand { - /// Open a window by its ID (eg. `zebar open bar`). + /// Open a window by its config path. + /// + /// Config path is relative within the Zebar config directory (e.g. + /// `zebar open ./material/config.yaml`). + /// + /// Starts Zebar if it is not already running. Open(OpenWindowArgs), + + /// Open all default windows. + /// + /// Starts Zebar if it is not already running. + OpenAll(OpenAllWindowsArgs), + /// Output available monitors. Monitors(OutputMonitorsArgs), + + /// Used when Zebar is launched with no arguments. + /// + /// If Zebar is already running, this command will no-op, otherwise it + /// will start Zebar and open all default windows. + #[clap(hide = true)] + Empty, } -#[derive(Args, Debug)] +#[derive(Args, Clone, Debug, PartialEq)] pub struct OpenWindowArgs { - /// ID of the window to open (eg. `bar`). - pub window_id: String, + /// Relative file path to window config within the Zebar config + /// directory. + pub config_path: PathBuf, + + /// Absolute or relative path to the Zebar config directory. + /// + /// The default path is `%userprofile%/.glzr/zebar/` + #[clap(long, value_hint = clap::ValueHint::FilePath)] + pub config_dir: Option, +} - /// Arguments to pass to the window. +#[derive(Args, Clone, Debug, PartialEq)] +pub struct OpenAllWindowsArgs { + /// Absolute or relative path to the Zebar config directory. /// - /// These become available via the `self` provider. - #[clap(short, long, num_args = 1.., value_parser=parse_open_args)] - pub args: Option>, + /// The default path is `%userprofile%/.glzr/zebar/` + #[clap(long, value_hint = clap::ValueHint::FilePath)] + pub config_dir: Option, } -#[derive(Args, Debug)] +#[derive(Args, Clone, Debug, PartialEq)] pub struct OutputMonitorsArgs { /// Use ASCII NUL character (character code 0) instead of newlines /// for delimiting monitors. @@ -41,7 +75,7 @@ pub struct OutputMonitorsArgs { pub print0: bool, } -/// Print to stdout/stderror and exit the process. +/// Prints to stdout/stderror and exits the process. pub fn print_and_exit(output: anyhow::Result) { match output { Ok(output) => { @@ -54,15 +88,3 @@ pub fn print_and_exit(output: anyhow::Result) { } } } - -/// Parses arguments passed to the `open` CLI command into a string tuple. -fn parse_open_args( - input: &str, -) -> anyhow::Result<(String, String), String> { - let mut parts = input.split('='); - - match (parts.next(), parts.next()) { - (Some(key), Some(value)) => Ok((key.into(), value.into())), - _ => Err("Arguments must be of format KEY1=VAL1".into()), - } -} diff --git a/packages/desktop/src/commands.rs b/packages/desktop/src/commands.rs new file mode 100644 index 00000000..ed7b0f6b --- /dev/null +++ b/packages/desktop/src/commands.rs @@ -0,0 +1,93 @@ +use std::{path::PathBuf, sync::Arc}; + +use tauri::{State, Window}; + +use crate::{ + common::WindowExt, + config::Config, + providers::{ProviderConfig, ProviderManager}, + window_factory::{WindowFactory, WindowState}, +}; + +#[tauri::command] +pub async fn get_window_state( + window_id: String, + window_factory: State<'_, Arc>, +) -> anyhow::Result, String> { + Ok(window_factory.state_by_id(&window_id).await) +} + +#[tauri::command] +pub async fn open_window( + config_path: String, + config: State<'_, Arc>, + window_factory: State<'_, Arc>, +) -> anyhow::Result<(), String> { + let window_config = config + .window_config_by_path(&PathBuf::from(config_path)) + .await + .and_then(|opt| { + opt.ok_or_else(|| anyhow::anyhow!("Window config not found.")) + }) + .map_err(|err| err.to_string())?; + + window_factory + .open(window_config) + .await + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub async fn listen_provider( + config_hash: String, + config: ProviderConfig, + tracked_access: Vec, + provider_manager: State<'_, Arc>, +) -> anyhow::Result<(), String> { + provider_manager + .create(config_hash, config, tracked_access) + .await + .map_err(|err| err.to_string()) +} + +#[tauri::command] +pub async fn unlisten_provider( + config_hash: String, + provider_manager: State<'_, Arc>, +) -> anyhow::Result<(), String> { + provider_manager + .destroy(config_hash) + .await + .map_err(|err| err.to_string()) +} + +/// Tauri's implementation of `always_on_top` places the window above +/// all normal windows (but not the MacOS menu bar). The following instead +/// sets the z-order of the window to be above the menu bar. +#[tauri::command] +pub fn set_always_on_top(window: Window) -> anyhow::Result<(), String> { + #[cfg(target_os = "macos")] + let res = window.set_above_menu_bar(); + + #[cfg(not(target_os = "macos"))] + let res = window.set_always_on_top(true); + + res.map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn set_skip_taskbar( + window: Window, + skip: bool, +) -> anyhow::Result<(), String> { + window + .set_skip_taskbar(skip) + .map_err(|err| err.to_string())?; + + #[cfg(target_os = "windows")] + window + .set_tool_window(skip) + .map_err(|err| err.to_string())?; + + Ok(()) +} diff --git a/packages/desktop/src/common/format_bytes.rs b/packages/desktop/src/common/format_bytes.rs new file mode 100644 index 00000000..32afcf76 --- /dev/null +++ b/packages/desktop/src/common/format_bytes.rs @@ -0,0 +1,43 @@ +const SI_UNITS: [&'static str; 9] = + ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + +const IEC_UNITS: [&'static str; 9] = + ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + +/// Converts a byte value to its SI (decimal) representation. +/// +/// Returns a tuple of the value and the SI unit as a string. +pub fn to_si_bytes(bytes: f64) -> (f64, String) { + if bytes < 1. && bytes > -1. { + return (bytes, "B".into()); + } + + let exponent = std::cmp::min( + (bytes.abs().log10() / 3.).floor() as i32, + (SI_UNITS.len() - 1) as i32, + ); + + ( + bytes / 1000f64.powi(exponent), + SI_UNITS[exponent as usize].into(), + ) +} + +/// Converts a byte value to its IEC (binary) representation. +/// +/// Returns a tuple of the value and the IEC unit as a string. +pub fn to_iec_bytes(bytes: f64) -> (f64, String) { + if bytes <= 1. && bytes >= -1. { + return (bytes, "B".into()); + } + + let exponent = std::cmp::min( + (bytes.abs().log2() / 10.).floor() as i32, + (IEC_UNITS.len() - 1) as i32, + ); + + ( + bytes / 1024f64.powi(exponent), + IEC_UNITS[exponent as usize].into(), + ) +} diff --git a/packages/desktop/src/common/fs_util.rs b/packages/desktop/src/common/fs_util.rs new file mode 100644 index 00000000..9ff77e3a --- /dev/null +++ b/packages/desktop/src/common/fs_util.rs @@ -0,0 +1,55 @@ +use std::{fs, path::PathBuf}; + +use anyhow::Context; +use serde::de::DeserializeOwned; + +/// Reads a JSON file and parses it into the specified type. +/// +/// Returns the parsed type `T` if successful. +pub fn read_and_parse_json( + path: &PathBuf, +) -> anyhow::Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read file: {}", path.display()))?; + + let parsed = serde_json::from_str(&content).with_context(|| { + format!("Failed to parse JSON from file: {}", path.display()) + })?; + + Ok(parsed) +} + +/// Returns whether the path has the given extension. +pub fn has_extension(path: &PathBuf, extension: &str) -> bool { + path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with(extension)) + .unwrap_or(false) +} + +/// Recursively copies a directory and all its contents to a new file +/// location. +/// +/// Optionally replaces existing files in the destination directory if +/// `override_existing` is `true`. +pub fn copy_dir_all( + src_dir: &PathBuf, + dest_dir: &PathBuf, + override_existing: bool, +) -> anyhow::Result<()> { + fs::create_dir_all(&dest_dir)?; + + for entry in fs::read_dir(src_dir)? { + let entry = entry?; + let dest_path = dest_dir.join(entry.file_name()); + + if entry.file_type()?.is_dir() { + copy_dir_all(&entry.path(), &dest_path, override_existing)?; + } else if override_existing || !dest_path.exists() { + fs::copy(entry.path(), dest_path)?; + } + } + + Ok(()) +} diff --git a/packages/desktop/src/common/length_value.rs b/packages/desktop/src/common/length_value.rs new file mode 100644 index 00000000..2f905eb1 --- /dev/null +++ b/packages/desktop/src/common/length_value.rs @@ -0,0 +1,92 @@ +use std::str::FromStr; + +use anyhow::{bail, Context}; +use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct LengthValue { + pub amount: f32, + pub unit: LengthUnit, +} + +#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LengthUnit { + Percentage, + Pixel, +} + +impl LengthValue { + pub fn to_px(&self, total_px: i32) -> i32 { + match self.unit { + LengthUnit::Percentage => (self.amount * total_px as f32) as i32, + LengthUnit::Pixel => self.amount as i32, + } + } +} + +impl FromStr for LengthValue { + type Err = anyhow::Error; + + /// Parses a string containing a number followed by a unit (`px`, `%`). + /// Allows for negative numbers. + /// + /// Example: + /// ``` + /// LengthValue::from_str("100px") // { amount: 100.0, unit: LengthUnit::Pixel } + /// ``` + fn from_str(unparsed: &str) -> anyhow::Result { + let units_regex = Regex::new(r"([+-]?\d+)(%|px)?")?; + + let err_msg = format!( + "Not a valid length value '{}'. Must be of format '10px' or '10%'.", + unparsed + ); + + let captures = units_regex + .captures(unparsed) + .context(err_msg.to_string())?; + + let unit_str = captures.get(2).map_or("", |m| m.as_str()); + let unit = match unit_str { + "px" | "" => LengthUnit::Pixel, + "%" => LengthUnit::Percentage, + _ => bail!(err_msg), + }; + + let amount = captures + .get(1) + .and_then(|amount_str| f32::from_str(amount_str.into()).ok()) + // Store percentage units as a fraction of 1. + .map(|amount| match unit { + LengthUnit::Pixel => amount, + LengthUnit::Percentage => amount / 100.0, + }) + .context(err_msg.to_string())?; + + Ok(LengthValue { amount, unit }) + } +} + +/// Deserialize a `LengthValue` from either a string or a struct. +impl<'de> Deserialize<'de> for LengthValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum LengthValueDe { + Struct { amount: f32, unit: LengthUnit }, + String(String), + } + + match LengthValueDe::deserialize(deserializer)? { + LengthValueDe::Struct { amount, unit } => Ok(Self { amount, unit }), + LengthValueDe::String(str) => { + Self::from_str(&str).map_err(serde::de::Error::custom) + } + } + } +} diff --git a/packages/desktop/src/common/mod.rs b/packages/desktop/src/common/mod.rs new file mode 100644 index 00000000..328132b3 --- /dev/null +++ b/packages/desktop/src/common/mod.rs @@ -0,0 +1,11 @@ +mod format_bytes; +mod fs_util; +mod length_value; +mod path_ext; +mod window_ext; + +pub use format_bytes::*; +pub use fs_util::*; +pub use length_value::*; +pub use path_ext::*; +pub use window_ext::*; diff --git a/packages/desktop/src/common/path_ext.rs b/packages/desktop/src/common/path_ext.rs new file mode 100644 index 00000000..ca3a394d --- /dev/null +++ b/packages/desktop/src/common/path_ext.rs @@ -0,0 +1,59 @@ +use std::{ + fs, + path::{Component, Path, PathBuf, Prefix}, +}; + +pub trait PathExt +where + Self: AsRef, +{ + /// Like `std::fs::canonicalize()`, but on Windows it outputs paths + /// without UNC prefix. Similar to the `dunce::canonicalize` crate fn. + /// + /// Example: + /// ``` + /// let path = PathBuf::from("\\?\C:\\Users\\John\\Desktop\\test"); + /// path.canonicalize_pretty().unwrap(); // "C:\\Users\\John\\Desktop\\test" + /// ``` + fn to_absolute(&self) -> anyhow::Result; + + /// Converts the path to a unicode string. + /// + /// Short-hand for `.to_string_lossy().to_string()`. + fn to_unicode_string(&self) -> String; +} + +impl PathExt for PathBuf { + fn to_absolute(&self) -> anyhow::Result { + let canonicalized = fs::canonicalize(self)?; + + #[cfg(not(windows))] + { + Ok(canonicalized) + } + #[cfg(windows)] + { + let should_strip_unc = match canonicalized.components().next() { + Some(Component::Prefix(prefix)) => match prefix.kind() { + Prefix::VerbatimDisk(_) => true, + _ => false, + }, + _ => false, + }; + + let formatted = match should_strip_unc { + true => canonicalized + .to_str() + .and_then(|path| path.get(4..)) + .map_or(canonicalized.clone(), PathBuf::from), + false => canonicalized, + }; + + Ok(formatted) + } + } + + fn to_unicode_string(&self) -> String { + self.to_string_lossy().to_string() + } +} diff --git a/packages/desktop/src/util/window_ext.rs b/packages/desktop/src/common/window_ext.rs similarity index 100% rename from packages/desktop/src/util/window_ext.rs rename to packages/desktop/src/common/window_ext.rs diff --git a/packages/desktop/src/config.rs b/packages/desktop/src/config.rs new file mode 100644 index 00000000..b42995ad --- /dev/null +++ b/packages/desktop/src/config.rs @@ -0,0 +1,540 @@ +use std::{ + fs::{self}, + path::PathBuf, + sync::Arc, +}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use tauri::{path::BaseDirectory, AppHandle, Manager}; +use tokio::sync::{broadcast, Mutex}; +use tracing::{error, info}; + +use crate::common::{ + copy_dir_all, has_extension, read_and_parse_json, LengthValue, PathExt, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsConfig { + /// JSON schema URL to validate the settings file. + #[serde(rename = "$schema")] + schema: Option, + + /// Relative paths to window configs to launch on startup. + pub startup_configs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowConfig { + /// JSON schema URL to validate the window config file. + #[serde(rename = "$schema")] + schema: Option, + + /// Relative path to entry point HTML file. + pub html_path: PathBuf, + + /// Default options for when the window is opened. + pub launch_options: WindowLaunchOptions, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowLaunchOptions { + /// Whether to show the window above/below all others. + pub z_order: WindowZOrder, + + /// Whether the window should be shown in the taskbar. + pub shown_in_taskbar: bool, + + /// Whether the window should be focused when opened. + pub focused: bool, + + /// Whether the window should have resize handles. + pub resizable: bool, + + /// Whether the window frame should be transparent. + pub transparent: bool, + + /// Where to place the window. + pub placements: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WindowZOrder { + AlwaysOnBottom, + AlwaysOnTop, + Normal, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowPlacement { + /// Anchor-point of the window. + pub anchor: WindowAnchor, + + /// Offset from the anchor-point. + pub offset_x: LengthValue, + + /// Offset from the anchor-point. + pub offset_y: LengthValue, + + /// Width of the window in % or physical pixels. + pub width: LengthValue, + + /// Height of the window in % or physical pixels. + pub height: LengthValue, + + /// Monitor(s) to place the window on. + pub monitor_selection: MonitorSelection, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WindowAnchor { + TopLeft, + TopCenter, + TopRight, + CenterLeft, + Center, + CenterRight, + BottomLeft, + BottomCenter, + BottomRight, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MonitorSelection { + All, + Primary, + Secondary, + Index(usize), + Name(String), +} + +#[derive(Debug)] +pub struct Config { + /// Handle to the Tauri application. + app_handle: AppHandle, + + /// Directory where config files are stored. + pub config_dir: PathBuf, + + /// Global settings. + pub settings: Arc>, + + /// List of window configs. + pub window_configs: Arc>>, + + _settings_change_rx: broadcast::Receiver, + + pub settings_change_tx: broadcast::Sender, + + _window_configs_change_rx: broadcast::Receiver>, + + pub window_configs_change_tx: broadcast::Sender>, +} + +#[derive(Clone, Debug)] +pub struct WindowConfigEntry { + /// Absolute path to the window config file. + pub config_path: PathBuf, + + /// Absolute path to the window's HTML file. + pub html_path: PathBuf, + + /// Parsed window config. + pub config: WindowConfig, +} + +impl Config { + /// Reads the config files within the config directory. + /// + /// Returns a new `Config` instance. + pub fn new( + app_handle: &AppHandle, + config_dir_override: Option, + ) -> anyhow::Result { + let config_dir = match config_dir_override { + Some(dir) => dir, + None => app_handle + .path() + .resolve(".glzr/zebar", BaseDirectory::Home) + .context("Unable to get home directory.")?, + } + .to_absolute()? + .into(); + + let settings = Self::read_settings_or_init(app_handle, &config_dir)?; + let window_configs = Self::read_window_configs(&config_dir)?; + + let (settings_change_tx, _settings_change_rx) = broadcast::channel(16); + let (window_configs_change_tx, _window_configs_change_rx) = + broadcast::channel(16); + + Ok(Self { + app_handle: app_handle.clone(), + config_dir, + settings: Arc::new(Mutex::new(settings)), + window_configs: Arc::new(Mutex::new(window_configs)), + _settings_change_rx, + settings_change_tx, + _window_configs_change_rx, + window_configs_change_tx, + }) + } + + /// Re-evaluates config files within the config directory. + pub async fn reload(&self) -> anyhow::Result<()> { + let new_settings = + Self::read_settings_or_init(&self.app_handle, &self.config_dir)?; + let new_window_configs = Self::read_window_configs(&self.config_dir)?; + + { + let mut settings = self.settings.lock().await; + *settings = new_settings.clone(); + } + + { + let mut window_configs = self.window_configs.lock().await; + *window_configs = new_window_configs.clone(); + } + + self.settings_change_tx.send(new_settings)?; + self.window_configs_change_tx.send(new_window_configs)?; + + Ok(()) + } + + /// Reads the global settings file or initializes it with the starter. + /// + /// Returns the parsed `SettingsConfig`. + fn read_settings_or_init( + app_handle: &AppHandle, + dir: &PathBuf, + ) -> anyhow::Result { + let settings = Self::read_settings(&dir)?; + + match settings { + Some(settings) => Ok(settings), + None => { + Self::create_from_examples(app_handle, dir)?; + + Ok( + Self::read_settings(&dir)? + .context("Failed to create settings config.")?, + ) + } + } + } + + /// Reads the global settings file. + /// + /// Returns the parsed `SettingsConfig` if found. + fn read_settings( + dir: &PathBuf, + ) -> anyhow::Result> { + let settings_path = dir.join("settings.json"); + + match settings_path.exists() { + false => Ok(None), + true => read_and_parse_json(&settings_path), + } + } + + /// Writes to the global settings file. + async fn write_settings( + &self, + new_settings: SettingsConfig, + ) -> anyhow::Result<()> { + let settings_path = self.config_dir.join("settings.json"); + + fs::write( + &settings_path, + serde_json::to_string_pretty(&new_settings)?, + )?; + + let mut settings = self.settings.lock().await; + *settings = new_settings.clone(); + + self.settings_change_tx.send(new_settings)?; + + Ok(()) + } + + /// Recursively aggregates all valid window configs in the given + /// directory. + /// + /// Returns a list of `ConfigEntry` instances. + fn read_window_configs( + dir: &PathBuf, + ) -> anyhow::Result> { + let mut configs = Vec::new(); + + let entries = fs::read_dir(dir).with_context(|| { + format!("Failed to read directory: {}", dir.display()) + })?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Recursively aggregate configs in subdirectories. + configs.extend(Self::read_window_configs(&path)?); + } else if has_extension(&path, ".zebar.json") { + let parse_res = read_and_parse_json::(&path) + .and_then(|config| { + let config_path = path.to_absolute()?; + + let html_path = path + .parent() + .and_then(|parent| { + parent.join(&config.html_path).to_absolute().ok() + }) + .with_context(|| { + format!( + "HTML file not found at {} for config {}.", + config.html_path.display(), + config_path.display() + ) + })?; + + Ok(WindowConfigEntry { + config, + config_path, + html_path, + }) + }); + + match parse_res { + Ok(config) => { + info!( + "Found valid window config at: {}", + config.config_path.display() + ); + + configs.push(config); + } + Err(err) => { + error!( + "Failed to parse config at {}: {:?}", + path.display(), + err + ); + } + } + } + } + + Ok(configs) + } + + /// Initializes settings and window configs at the given path. + /// + /// `settings.json` is initialized with either `starter/vanilla.json` or + /// `starter/with-glazewm.json` as startup config. Window configs are + /// initialized from `examples/` directory. + fn create_from_examples( + app_handle: &AppHandle, + config_dir: &PathBuf, + ) -> anyhow::Result<()> { + let starter_path = app_handle + .path() + .resolve("../../examples", BaseDirectory::Resource) + .context("Unable to resolve starter config resource.")?; + + info!( + "Copying starter configs from {} to {}.", + starter_path.display(), + config_dir.display() + ); + + copy_dir_all(&starter_path, config_dir, false)?; + + let default_startup_config = match is_app_installed("glazewm") { + true => "starter/with-glazewm.zebar.json", + false => "starter/vanilla.zebar.json", + }; + + let default_settings = SettingsConfig { + schema: Some("TODO".into()), + startup_configs: vec![default_startup_config.into()], + }; + + let settings_path = config_dir.join("settings.json"); + fs::write( + &settings_path, + serde_json::to_string_pretty(&default_settings)?, + )?; + + Ok(()) + } + + pub async fn window_configs(&self) -> Vec { + self.window_configs.lock().await.clone() + } + + /// Returns the window configs to open on startup. + pub async fn startup_window_configs( + &self, + ) -> anyhow::Result> { + let startup_configs = + { self.settings.lock().await.startup_configs.clone() }; + + let mut result = Vec::new(); + + for config_path in startup_configs { + let abs_config_path = self.join_config_dir(&config_path); + let config = self + .window_config_by_path(&abs_config_path) + .await + .unwrap_or(None) + .context(format!( + "Failed to get window config at {}.", + abs_config_path.display() + ))?; + + result.push(config); + } + + Ok(result) + } + + /// Adds the given config to be launched on startup. + /// + /// Config path must be absolute. + pub async fn add_startup_config( + &self, + config_path: &PathBuf, + ) -> anyhow::Result<()> { + let startup_configs = self.startup_window_configs().await?; + + // Check if the config is already set to be launched on startup. + if startup_configs + .iter() + .find(|config| config.config_path == *config_path) + .is_some() + { + return Ok(()); + } + + // Add the path to startup configs. + let mut new_settings = { self.settings.lock().await.clone() }; + new_settings + .startup_configs + .push(self.strip_config_dir(config_path)?); + + self.write_settings(new_settings).await?; + + Ok(()) + } + + /// Removes the given config from being launched on startup. + /// + /// Config path must be absolute. + pub async fn remove_startup_config( + &self, + config_path: &PathBuf, + ) -> anyhow::Result<()> { + let rel_path = self.strip_config_dir(config_path)?; + + let mut new_settings = { self.settings.lock().await.clone() }; + new_settings + .startup_configs + .retain(|path| path != &rel_path); + + self.write_settings(new_settings).await?; + + Ok(()) + } + + /// Joins the given path with the config directory path. + /// + /// Returns an absolute path. + pub fn join_config_dir(&self, config_path: &PathBuf) -> PathBuf { + self.config_dir.join(config_path) + } + + /// Strips the config directory path from the given path. + /// + /// Returns a relative path. + pub fn strip_config_dir( + &self, + config_path: &PathBuf, + ) -> anyhow::Result { + config_path + .strip_prefix(&self.config_dir) + .context("Failed to strip config directory path.") + .map(Into::into) + } + + /// Returns the window config at the given absolute path. + pub async fn window_config_by_path( + &self, + config_path: &PathBuf, + ) -> anyhow::Result> { + let formatted_config_path = + PathBuf::from(config_path).to_absolute()?; + + let window_configs = self.window_configs.lock().await; + let config_entry = window_configs + .iter() + .find(|entry| entry.config_path == formatted_config_path); + + Ok(config_entry.cloned()) + } + + /// Opens the config directory in the OS-dependent file explorer. + pub fn open_config_dir(&self) -> anyhow::Result<()> { + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer") + .arg(self.config_dir.clone()) + .spawn()?; + } + + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(self.config_dir.clone()) + .arg("-R") + .spawn()?; + } + + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(self.config_dir.clone()) + .spawn()?; + } + + Ok(()) + } +} + +/// Checks if an application is installed and available in the system PATH. +/// +/// Returns `true` if the application is found in PATH, `false` otherwise. +fn is_app_installed(app_name: &str) -> bool { + #[cfg(target_os = "windows")] + { + std::process::Command::new("where") + .arg(app_name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + std::process::Command::new("which") + .arg(app_name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } +} diff --git a/packages/desktop/src/main.rs b/packages/desktop/src/main.rs index b336dfb5..18a2ae1d 100644 --- a/packages/desktop/src/main.rs +++ b/packages/desktop/src/main.rs @@ -1,118 +1,82 @@ +// Prevent additional console window on Windows in release mode. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![feature(async_closure)] -use std::{collections::HashMap, env}; +#![feature(iterator_try_collect)] +use std::{env, sync::Arc}; + +use anyhow::Context; use clap::Parser; -use cli::OpenWindowArgs; -use providers::config::ProviderConfig; -use tauri::{AppHandle, Manager, State, Window}; -use tracing::level_filters::LevelFilter; +use tauri::{async_runtime::block_on, Manager, RunEvent}; +use tokio::task; +use tracing::{error, info, level_filters::LevelFilter}; use tracing_subscriber::EnvFilter; -use window_factory::{WindowFactory, WindowState}; use crate::{ - cli::{Cli, CliCommand}, - monitors::get_monitors_str, - providers::provider_manager::ProviderManager, - sys_tray::setup_sys_tray, - util::window_ext::WindowExt, + cli::{Cli, CliCommand, OutputMonitorsArgs}, + config::Config, + monitor_state::MonitorState, + providers::ProviderManager, + sys_tray::SysTray, + window_factory::WindowFactory, }; mod cli; -mod monitors; +mod commands; +mod common; +mod config; +mod monitor_state; mod providers; mod sys_tray; -mod user_config; -mod util; mod window_factory; -#[tauri::command] -fn read_config_file( - config_path_override: Option<&str>, - app_handle: AppHandle, -) -> anyhow::Result { - user_config::read_file(config_path_override, app_handle) - .map_err(|err| err.to_string()) -} - -#[tauri::command] -async fn get_open_window_args( - window_label: String, - window_factory: State<'_, WindowFactory>, -) -> anyhow::Result, String> { - Ok(window_factory.state_by_window_label(window_label).await) -} - -#[tauri::command] -fn open_window( - window_id: String, - args: HashMap, - window_factory: State<'_, WindowFactory>, -) -> anyhow::Result<(), String> { - window_factory.try_open(OpenWindowArgs { - window_id, - args: Some(args.into_iter().collect()), - }); - - Ok(()) -} - -#[tauri::command] -async fn listen_provider( - config_hash: String, - config: ProviderConfig, - tracked_access: Vec, - provider_manager: State<'_, ProviderManager>, -) -> anyhow::Result<(), String> { - provider_manager - .create(config_hash, config, tracked_access) - .await - .map_err(|err| err.to_string()) -} +/// Main entry point for the application. +/// +/// Conditionally starts Zebar or runs a CLI command based on the given +/// subcommand. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Attach to parent console on Windows in release mode. + #[cfg(all(windows, not(debug_assertions)))] + { + use windows::Win32::System::Console::{ + AttachConsole, ATTACH_PARENT_PROCESS, + }; + let _ = unsafe { AttachConsole(ATTACH_PARENT_PROCESS) }; + } -#[tauri::command] -async fn unlisten_provider( - config_hash: String, - provider_manager: State<'_, ProviderManager>, -) -> anyhow::Result<(), String> { - provider_manager - .destroy(config_hash) - .await - .map_err(|err| err.to_string()) -} + let cli = Cli::parse(); -/// Tauri's implementation of `always_on_top` places the window above -/// all normal windows (but not the MacOS menu bar). The following instead -/// sets the z-order of the window to be above the menu bar. -#[tauri::command] -fn set_always_on_top(window: Window) -> anyhow::Result<(), String> { - #[cfg(target_os = "macos")] - let res = window.set_above_menu_bar(); + match cli.command() { + CliCommand::Monitors(args) => output_monitors(args), + _ => { + let start_res = start_app(cli); - #[cfg(not(target_os = "macos"))] - let res = window.set_always_on_top(true); + // If unable to start Zebar, the error is fatal and a message dialog + // is shown. + if let Err(err) = &start_res { + // TODO: Show error dialog. + error!("{:?}", err); + }; - res.map_err(|err| err.to_string()) + start_res + } + } } -#[tauri::command] -fn set_skip_taskbar( - window: Window, - skip: bool, -) -> anyhow::Result<(), String> { - window - .set_skip_taskbar(skip) - .map_err(|err| err.to_string())?; - - #[cfg(target_os = "windows")] - window - .set_tool_window(skip) - .map_err(|err| err.to_string())?; +/// Prints available monitors to console. +fn output_monitors(args: OutputMonitorsArgs) -> anyhow::Result<()> { + let _ = tauri::Builder::default().setup(|app| { + let monitors = MonitorState::new(app.handle()); + cli::print_and_exit(monitors.output_str(args)); + Ok(()) + }); Ok(()) } -#[tokio::main] -async fn main() { +/// Starts Zebar - either with a specific window or all windows. +fn start_app(cli: Cli) -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( EnvFilter::from_env("LOG_LEVEL") @@ -122,68 +86,211 @@ async fn main() { tauri::async_runtime::set(tokio::runtime::Handle::current()); - tauri::Builder::default() - .setup(|app| { - let cli = Cli::parse(); - - // Since most Tauri plugins and setup is not needed for the - // `monitors` CLI command, the setup is conditional based on - // the CLI command. - match cli.command { - CliCommand::Monitors(args) => { - let monitors_str = get_monitors_str(app, args); - cli::print_and_exit(monitors_str); - Ok(()) - } - CliCommand::Open(open_args) => { + let app = tauri::Builder::default() + .setup(move |app| { + task::block_in_place(|| { + block_on(async move { + let config_dir_override = match cli.command() { + CliCommand::Open(args) => args.config_dir, + CliCommand::OpenAll(args) => args.config_dir, + _ => None, + }; + + // Initialize `Config` in Tauri state. + let config = + Arc::new(Config::new(app.handle(), config_dir_override)?); + app.manage(config.clone()); + + // Initialize `MonitorState` in Tauri state. + let monitor_state = Arc::new(MonitorState::new(app.handle())); + app.manage(monitor_state.clone()); + + // Initialize `WindowFactory` in Tauri state. + let window_factory = Arc::new(WindowFactory::new( + app.handle(), + config.clone(), + monitor_state.clone(), + )); + app.manage(window_factory.clone()); + // If this is not the first instance of the app, this will emit - // within the original instance and exit immediately. - app.handle().plugin(tauri_plugin_single_instance::init( - move |app, args, _| { - let cli = Cli::parse_from(args); - - // CLI command is guaranteed to be an open command here. - if let CliCommand::Open(args) = cli.command { - app.state::().try_open(args); - } - }, - ))?; + // within the original instance and exit immediately. The CLI + // command is guaranteed to be one of the open commands here. + setup_single_instance( + app, + config.clone(), + window_factory.clone(), + )?; // Prevent windows from showing up in the dock on MacOS. #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); - // Open window with the given args and initialize `WindowFactory` - // in Tauri state. - let window_factory = WindowFactory::new(app.handle()); - window_factory.try_open(open_args); - app.manage(window_factory); + // Allow assets to be resolved from the config directory. + app + .asset_protocol_scope() + .allow_directory(&config.config_dir, true)?; app.handle().plugin(tauri_plugin_shell::init())?; app.handle().plugin(tauri_plugin_http::init())?; app.handle().plugin(tauri_plugin_dialog::init())?; // Initialize `ProviderManager` in Tauri state. - let mut manager = ProviderManager::new(); - manager.init(app.handle()); + let manager = Arc::new(ProviderManager::new(app.handle())); app.manage(manager); + // Open windows based on CLI command. + open_windows_by_cli_command( + cli, + config.clone(), + window_factory.clone(), + ) + .await?; + // Add application icon to system tray. - setup_sys_tray(app)?; + let tray = SysTray::new( + app.handle(), + config.clone(), + window_factory.clone(), + ) + .await?; + + listen_events(config, monitor_state, window_factory, tray); Ok(()) - } - } + }) + }) }) .invoke_handler(tauri::generate_handler![ - read_config_file, - get_open_window_args, - open_window, - listen_provider, - unlisten_provider, - set_always_on_top, - set_skip_taskbar + commands::get_window_state, + commands::open_window, + commands::listen_provider, + commands::unlisten_provider, + commands::set_always_on_top, + commands::set_skip_taskbar ]) - .run(tauri::generate_context!()) - .expect("Failed to build Tauri application."); + .build(tauri::generate_context!())?; + + app.run(|_, event| { + if let RunEvent::ExitRequested { code, api, .. } = &event { + if code.is_none() { + // Keep the message loop running even if all windows are closed. + api.prevent_exit(); + } + } + }); + + Ok(()) +} + +fn listen_events( + config: Arc, + monitor_state: Arc, + window_factory: Arc, + tray: Arc, +) { + let mut window_open_rx = window_factory.open_tx.subscribe(); + let mut window_close_rx = window_factory.close_tx.subscribe(); + let mut settings_change_rx = config.settings_change_tx.subscribe(); + let mut monitors_change_rx = monitor_state.change_tx.subscribe(); + let mut window_configs_change_rx = + config.window_configs_change_tx.subscribe(); + + task::spawn(async move { + loop { + let res = tokio::select! { + Ok(_) = window_open_rx.recv() => { + info!("Window opened."); + tray.refresh().await + }, + Ok(_) = window_close_rx.recv() => { + info!("Window closed."); + tray.refresh().await + }, + Ok(_) = settings_change_rx.recv() => { + info!("Settings changed."); + tray.refresh().await + }, + Ok(_) = monitors_change_rx.recv() => { + info!("Monitors changed."); + window_factory.relaunch_all().await + }, + Ok(_) = window_configs_change_rx.recv() => { + info!("Window configs changed."); + window_factory.relaunch_all().await + }, + }; + + if let Err(err) = res { + error!("{:?}", err); + } + } + }); +} + +/// Setup single instance Tauri plugin. +fn setup_single_instance( + app: &tauri::App, + config: Arc, + window_factory: Arc, +) -> anyhow::Result<()> { + app.handle().plugin(tauri_plugin_single_instance::init( + move |_, args, _| { + let config = config.clone(); + let window_factory = window_factory.clone(); + + task::spawn(async move { + let res = match Cli::try_parse_from(args) { + Ok(cli) => { + // No-op if no subcommand is provided. + if cli.command() != CliCommand::Empty { + open_windows_by_cli_command(cli, config, window_factory) + .await + } else { + Ok(()) + } + } + _ => Err(anyhow::anyhow!("Failed to parse CLI arguments.")), + }; + + if let Err(err) = res { + error!("{:?}", err); + } + }); + }, + ))?; + + Ok(()) +} + +/// Opens windows based on CLI command. +async fn open_windows_by_cli_command( + cli: Cli, + config: Arc, + window_factory: Arc, +) -> anyhow::Result<()> { + let window_configs = match cli.command() { + CliCommand::Open(args) => { + let window_config = config + .window_config_by_path(&config.join_config_dir(&args.config_path)) + .await? + .with_context(|| { + format!( + "Window config not found at {}.", + args.config_path.display() + ) + })?; + + vec![window_config] + } + _ => config.startup_window_configs().await?, + }; + + for window_config in window_configs { + if let Err(err) = window_factory.open(window_config).await { + error!("Failed to open window: {:?}", err); + } + } + + Ok(()) } diff --git a/packages/desktop/src/monitor_state.rs b/packages/desktop/src/monitor_state.rs new file mode 100644 index 00000000..79ba359e --- /dev/null +++ b/packages/desktop/src/monitor_state.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; + +use anyhow::bail; +use tauri::AppHandle; +use tokio::{ + sync::{broadcast, Mutex}, + task, +}; +use tracing::info; + +use crate::{cli::OutputMonitorsArgs, config::MonitorSelection}; + +pub struct MonitorState { + /// Handle to the Tauri application. + app_handle: AppHandle, + + _change_rx: broadcast::Receiver>, + + pub change_tx: broadcast::Sender>, + + /// Available monitors sorted from left-to-right and top-to-bottom. + monitors: Arc>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Monitor { + pub name: Option, + pub is_primary: bool, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub scale_factor: f64, +} + +impl MonitorState { + /// Creates a new `MonitorState` instance. + pub fn new(app_handle: &AppHandle) -> Self { + let (change_tx, _change_rx) = broadcast::channel(16); + + let monitors = + Arc::new(Mutex::new(Self::available_monitors(app_handle))); + + Self::listen_changes( + app_handle.clone(), + monitors.clone(), + change_tx.clone(), + ); + + Self { + app_handle: app_handle.clone(), + monitors, + _change_rx, + change_tx, + } + } + + /// Listens for display setting changes. + /// + /// Updates monitor state on scaling changes, monitor connections, and + /// monitor disconnections. Does not update on working area changes. + fn listen_changes( + app_handle: AppHandle, + monitors: Arc>>, + change_tx: broadcast::Sender>, + ) { + task::spawn(async move { + loop { + let new_monitors = Self::available_monitors(&app_handle); + let mut monitors = monitors.lock().await; + + if *monitors != new_monitors { + info!("Detected change in monitors."); + let _ = change_tx.send(new_monitors.clone()); + *monitors = new_monitors; + } + + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + } + }); + } + + /// Gets available monitors on the system. + /// + /// Returns a vector of `Monitor` instances sorted from left-to-right and + /// top-to-bottom. + fn available_monitors(app_handle: &AppHandle) -> Vec { + let primary_monitor = app_handle.primary_monitor().unwrap_or(None); + + let mut monitors = app_handle + .available_monitors() + .map(|monitors| { + monitors + .into_iter() + .map(|monitor| Monitor { + name: monitor.name().cloned(), + is_primary: primary_monitor + .as_ref() + .map(|m| m.name() == monitor.name()) + .unwrap_or(false), + x: monitor.position().x, + y: monitor.position().y, + width: monitor.size().width, + height: monitor.size().height, + scale_factor: monitor.scale_factor(), + }) + .collect() + }) + .unwrap_or(Vec::new()); + + // Sort monitors from left-to-right, top-to-bottom. + monitors.sort_by(|monitor_a, monitor_b| { + if monitor_a.x == monitor_b.x { + monitor_a.y.cmp(&monitor_b.y) + } else { + monitor_a.x.cmp(&monitor_b.x) + } + }); + + monitors + } + + /// Returns a string representation of the monitors. + pub fn output_str( + &self, + args: OutputMonitorsArgs, + ) -> anyhow::Result { + let monitors = self.monitors.try_lock()?; + + if monitors.len() == 0 { + bail!("No monitors found") + } + + let mut monitors_str = String::new(); + + for monitor in monitors.iter() { + monitors_str += &format!( + "MONITOR_NAME=\"{}\" MONITOR_X=\"{}\" MONITOR_Y=\"{}\" MONITOR_WIDTH=\"{}\" MONITOR_HEIGHT=\"{}\" MONITOR_SCALE_FACTOR=\"{}\"", + monitor.name.clone().unwrap_or("".into()), + monitor.x, + monitor.y, + monitor.width, + monitor.height, + monitor.scale_factor + ); + + monitors_str += match args.print0 { + true => "\0", + false => "\n", + }; + } + + Ok(monitors_str) + } + + pub async fn monitors_by_selection( + &self, + monitor_selection: &MonitorSelection, + ) -> Vec { + let monitors = self.monitors.lock().await.clone(); + + match monitor_selection { + MonitorSelection::All => monitors, + MonitorSelection::Primary => monitors + .into_iter() + .filter(|monitor| monitor.is_primary) + .collect(), + MonitorSelection::Secondary => monitors + .into_iter() + .filter(|monitor| !monitor.is_primary) + .collect(), + MonitorSelection::Index(index) => { + monitors.get(*index).cloned().into_iter().collect() + } + MonitorSelection::Name(name) => monitors + .into_iter() + .filter(|monitor| monitor.name.as_deref() == Some(name)) + .collect(), + } + } +} diff --git a/packages/desktop/src/monitors.rs b/packages/desktop/src/monitors.rs deleted file mode 100644 index 346961c1..00000000 --- a/packages/desktop/src/monitors.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::{bail, Context}; -use tauri::{App, Runtime}; - -use crate::cli::OutputMonitorsArgs; - -pub fn get_monitors_str( - app: &mut App, - args: OutputMonitorsArgs, -) -> anyhow::Result { - let monitors = app - .available_monitors() - .context("Unable to detect monitors")?; - - if monitors.len() == 0 { - bail!("No monitors found") - } - - let mut monitors_str = String::new(); - - for monitor in monitors { - monitors_str += &format!( - "MONITOR_NAME=\"{}\" MONITOR_X=\"{}\" MONITOR_Y=\"{}\" MONITOR_WIDTH=\"{}\" MONITOR_HEIGHT=\"{}\" MONITOR_SCALE_FACTOR=\"{}\"", - monitor.name().context("Unable to read monitor name")?, - monitor.position().x, - monitor.position().y, - monitor.size().width, - monitor.size().height, - monitor.scale_factor() - ); - - monitors_str += match args.print0 { - true => "\0", - false => "\n", - }; - } - - Ok(monitors_str) -} diff --git a/packages/desktop/src/providers/battery/config.rs b/packages/desktop/src/providers/battery/config.rs index 5836bc25..85da02f8 100644 --- a/packages/desktop/src/providers/battery/config.rs +++ b/packages/desktop/src/providers/battery/config.rs @@ -1,11 +1,7 @@ use serde::Deserialize; -use crate::impl_interval_config; - #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "battery")] +#[serde(rename_all = "camelCase")] pub struct BatteryProviderConfig { pub refresh_interval: u64, } - -impl_interval_config!(BatteryProviderConfig); diff --git a/packages/desktop/src/providers/battery/provider.rs b/packages/desktop/src/providers/battery/provider.rs index 942fa365..d716a77c 100644 --- a/packages/desktop/src/providers/battery/provider.rs +++ b/packages/desktop/src/providers/battery/provider.rs @@ -1,7 +1,4 @@ -use std::sync::Arc; - use anyhow::Context; -use async_trait::async_trait; use starship_battery::{ units::{ electric_potential::volt, power::watt, ratio::percent, @@ -9,42 +6,31 @@ use starship_battery::{ }, Manager, State, }; -use tokio::task::AbortHandle; -use super::{BatteryProviderConfig, BatteryVariables}; -use crate::providers::{ - provider::IntervalProvider, variables::ProviderVariables, -}; +use super::{BatteryOutput, BatteryProviderConfig}; +use crate::{impl_interval_provider, providers::ProviderOutput}; pub struct BatteryProvider { - pub config: Arc, - abort_handle: Option, - battery_manager: Arc, + config: BatteryProviderConfig, } impl BatteryProvider { - pub fn new( - config: BatteryProviderConfig, - ) -> anyhow::Result { - let manager = Manager::new()?; + pub fn new(config: BatteryProviderConfig) -> BatteryProvider { + BatteryProvider { config } + } - Ok(BatteryProvider { - config: Arc::new(config), - abort_handle: None, - battery_manager: Arc::new(manager), - }) + fn refresh_interval_ms(&self) -> u64 { + self.config.refresh_interval } - /// Battery manager from `starship_battery` is not thread-safe, so it - /// requires its own non-async function. - fn get_variables(manager: &Manager) -> anyhow::Result { - let first_battery = manager + async fn run_interval(&self) -> anyhow::Result { + let battery = Manager::new()? .batteries() .and_then(|mut batteries| batteries.nth(0).transpose()) .unwrap_or(None) - .context("No battery found."); + .context("No battery found.")?; - first_battery.map(|battery| BatteryVariables { + Ok(ProviderOutput::Battery(BatteryOutput { charge_percent: battery.state_of_charge().get::(), health_percent: battery.state_of_health().get::(), state: battery.state().to_string(), @@ -58,37 +44,8 @@ impl BatteryProvider { power_consumption: battery.energy_rate().get::(), voltage: battery.voltage().get::(), cycle_count: battery.cycle_count(), - }) + })) } } -#[async_trait] -impl IntervalProvider for BatteryProvider { - type Config = BatteryProviderConfig; - type State = Manager; - - fn config(&self) -> Arc { - self.config.clone() - } - - fn state(&self) -> Arc { - self.battery_manager.clone() - } - - fn abort_handle(&self) -> &Option { - &self.abort_handle - } - - fn set_abort_handle(&mut self, abort_handle: AbortHandle) { - self.abort_handle = Some(abort_handle) - } - - async fn get_refreshed_variables( - _: &BatteryProviderConfig, - battery_manager: &Manager, - ) -> anyhow::Result { - Ok(ProviderVariables::Battery(Self::get_variables( - battery_manager, - )?)) - } -} +impl_interval_provider!(BatteryProvider); diff --git a/packages/desktop/src/providers/battery/variables.rs b/packages/desktop/src/providers/battery/variables.rs index 0bc96e9c..dbb4cf57 100644 --- a/packages/desktop/src/providers/battery/variables.rs +++ b/packages/desktop/src/providers/battery/variables.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct BatteryVariables { +pub struct BatteryOutput { pub charge_percent: f32, pub health_percent: f32, pub state: String, diff --git a/packages/desktop/src/providers/cpu/config.rs b/packages/desktop/src/providers/cpu/config.rs index aae3966d..e186ce4b 100644 --- a/packages/desktop/src/providers/cpu/config.rs +++ b/packages/desktop/src/providers/cpu/config.rs @@ -1,11 +1,7 @@ use serde::Deserialize; -use crate::impl_interval_config; - #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "cpu")] +#[serde(rename_all = "camelCase")] pub struct CpuProviderConfig { pub refresh_interval: u64, } - -impl_interval_config!(CpuProviderConfig); diff --git a/packages/desktop/src/providers/cpu/provider.rs b/packages/desktop/src/providers/cpu/provider.rs index 9af50d1a..7d5e7c59 100644 --- a/packages/desktop/src/providers/cpu/provider.rs +++ b/packages/desktop/src/providers/cpu/provider.rs @@ -1,17 +1,13 @@ use std::sync::Arc; -use async_trait::async_trait; use sysinfo::System; -use tokio::{sync::Mutex, task::AbortHandle}; +use tokio::sync::Mutex; -use super::{CpuProviderConfig, CpuVariables}; -use crate::providers::{ - provider::IntervalProvider, variables::ProviderVariables, -}; +use super::{CpuOutput, CpuProviderConfig}; +use crate::{impl_interval_provider, providers::ProviderOutput}; pub struct CpuProvider { - pub config: Arc, - abort_handle: Option, + config: CpuProviderConfig, sysinfo: Arc>, } @@ -20,43 +16,18 @@ impl CpuProvider { config: CpuProviderConfig, sysinfo: Arc>, ) -> CpuProvider { - CpuProvider { - config: Arc::new(config), - abort_handle: None, - sysinfo, - } + CpuProvider { config, sysinfo } } -} - -#[async_trait] -impl IntervalProvider for CpuProvider { - type Config = CpuProviderConfig; - type State = Mutex; - fn config(&self) -> Arc { - self.config.clone() + fn refresh_interval_ms(&self) -> u64 { + self.config.refresh_interval } - fn state(&self) -> Arc> { - self.sysinfo.clone() - } - - fn abort_handle(&self) -> &Option { - &self.abort_handle - } - - fn set_abort_handle(&mut self, abort_handle: AbortHandle) { - self.abort_handle = Some(abort_handle) - } - - async fn get_refreshed_variables( - _: &CpuProviderConfig, - sysinfo: &Mutex, - ) -> anyhow::Result { - let mut sysinfo = sysinfo.lock().await; + async fn run_interval(&self) -> anyhow::Result { + let mut sysinfo = self.sysinfo.lock().await; sysinfo.refresh_cpu(); - Ok(ProviderVariables::Cpu(CpuVariables { + Ok(ProviderOutput::Cpu(CpuOutput { usage: sysinfo.global_cpu_info().cpu_usage(), frequency: sysinfo.global_cpu_info().frequency(), logical_core_count: sysinfo.cpus().len(), @@ -67,3 +38,5 @@ impl IntervalProvider for CpuProvider { })) } } + +impl_interval_provider!(CpuProvider); diff --git a/packages/desktop/src/providers/cpu/variables.rs b/packages/desktop/src/providers/cpu/variables.rs index f94d60b4..35a933eb 100644 --- a/packages/desktop/src/providers/cpu/variables.rs +++ b/packages/desktop/src/providers/cpu/variables.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct CpuVariables { +pub struct CpuOutput { pub frequency: u64, pub usage: f32, pub logical_core_count: usize, diff --git a/packages/desktop/src/providers/host/config.rs b/packages/desktop/src/providers/host/config.rs index 56e688fa..f56a1431 100644 --- a/packages/desktop/src/providers/host/config.rs +++ b/packages/desktop/src/providers/host/config.rs @@ -1,11 +1,7 @@ use serde::Deserialize; -use crate::impl_interval_config; - #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "host")] +#[serde(rename_all = "camelCase")] pub struct HostProviderConfig { pub refresh_interval: u64, } - -impl_interval_config!(HostProviderConfig); diff --git a/packages/desktop/src/providers/host/provider.rs b/packages/desktop/src/providers/host/provider.rs index b1f81c5f..d0be0164 100644 --- a/packages/desktop/src/providers/host/provider.rs +++ b/packages/desktop/src/providers/host/provider.rs @@ -1,59 +1,23 @@ -use std::sync::Arc; - -use async_trait::async_trait; use sysinfo::System; -use tokio::{sync::Mutex, task::AbortHandle}; -use super::{HostProviderConfig, HostVariables}; -use crate::providers::{ - provider::IntervalProvider, variables::ProviderVariables, -}; +use super::{HostOutput, HostProviderConfig}; +use crate::{impl_interval_provider, providers::ProviderOutput}; pub struct HostProvider { - pub config: Arc, - abort_handle: Option, - sysinfo: Arc>, + config: HostProviderConfig, } impl HostProvider { - pub fn new( - config: HostProviderConfig, - sysinfo: Arc>, - ) -> HostProvider { - HostProvider { - config: Arc::new(config), - abort_handle: None, - sysinfo, - } - } -} - -#[async_trait] -impl IntervalProvider for HostProvider { - type Config = HostProviderConfig; - type State = Mutex; - - fn config(&self) -> Arc { - self.config.clone() + pub fn new(config: HostProviderConfig) -> HostProvider { + HostProvider { config } } - fn state(&self) -> Arc> { - self.sysinfo.clone() + fn refresh_interval_ms(&self) -> u64 { + self.config.refresh_interval } - fn abort_handle(&self) -> &Option { - &self.abort_handle - } - - fn set_abort_handle(&mut self, abort_handle: AbortHandle) { - self.abort_handle = Some(abort_handle) - } - - async fn get_refreshed_variables( - _: &HostProviderConfig, - __: &Mutex, - ) -> anyhow::Result { - Ok(ProviderVariables::Host(HostVariables { + async fn run_interval(&self) -> anyhow::Result { + Ok(ProviderOutput::Host(HostOutput { hostname: System::host_name(), os_name: System::name(), os_version: System::os_version(), @@ -63,3 +27,5 @@ impl IntervalProvider for HostProvider { })) } } + +impl_interval_provider!(HostProvider); diff --git a/packages/desktop/src/providers/host/variables.rs b/packages/desktop/src/providers/host/variables.rs index 5b1b7137..15a7629b 100644 --- a/packages/desktop/src/providers/host/variables.rs +++ b/packages/desktop/src/providers/host/variables.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct HostVariables { +pub struct HostOutput { pub hostname: Option, pub os_name: Option, pub os_version: Option, diff --git a/packages/desktop/src/providers/ip/config.rs b/packages/desktop/src/providers/ip/config.rs index e88e4709..c2d8a597 100644 --- a/packages/desktop/src/providers/ip/config.rs +++ b/packages/desktop/src/providers/ip/config.rs @@ -1,11 +1,7 @@ use serde::Deserialize; -use crate::impl_interval_config; - #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "ip")] +#[serde(rename_all = "camelCase")] pub struct IpProviderConfig { pub refresh_interval: u64, } - -impl_interval_config!(IpProviderConfig); diff --git a/packages/desktop/src/providers/ip/provider.rs b/packages/desktop/src/providers/ip/provider.rs index 0fa936be..8ed18046 100644 --- a/packages/desktop/src/providers/ip/provider.rs +++ b/packages/desktop/src/providers/ip/provider.rs @@ -1,57 +1,29 @@ -use std::sync::Arc; - use anyhow::Context; -use async_trait::async_trait; use reqwest::Client; -use tokio::task::AbortHandle; -use super::{ipinfo_res::IpinfoRes, IpProviderConfig, IpVariables}; -use crate::providers::{ - provider::IntervalProvider, variables::ProviderVariables, -}; +use super::{ipinfo_res::IpinfoRes, IpOutput, IpProviderConfig}; +use crate::{impl_interval_provider, providers::ProviderOutput}; pub struct IpProvider { - pub config: Arc, - abort_handle: Option, - http_client: Arc, + config: IpProviderConfig, + http_client: Client, } impl IpProvider { pub fn new(config: IpProviderConfig) -> IpProvider { IpProvider { - config: Arc::new(config), - abort_handle: None, - http_client: Arc::new(Client::new()), + config, + http_client: Client::new(), } } -} - -#[async_trait] -impl IntervalProvider for IpProvider { - type Config = IpProviderConfig; - type State = Client; - - fn config(&self) -> Arc { - self.config.clone() - } - fn state(&self) -> Arc { - self.http_client.clone() + fn refresh_interval_ms(&self) -> u64 { + self.config.refresh_interval } - fn abort_handle(&self) -> &Option { - &self.abort_handle - } - - fn set_abort_handle(&mut self, abort_handle: AbortHandle) { - self.abort_handle = Some(abort_handle) - } - - async fn get_refreshed_variables( - _: &IpProviderConfig, - http_client: &Client, - ) -> anyhow::Result { - let res = http_client + async fn run_interval(&self) -> anyhow::Result { + let res = self + .http_client .get("https://ipinfo.io/json") .send() .await? @@ -60,7 +32,7 @@ impl IntervalProvider for IpProvider { let mut loc_parts = res.loc.split(','); - Ok(ProviderVariables::Ip(IpVariables { + Ok(ProviderOutput::Ip(IpOutput { address: res.ip, approx_city: res.city, approx_country: res.country, @@ -75,3 +47,5 @@ impl IntervalProvider for IpProvider { })) } } + +impl_interval_provider!(IpProvider); diff --git a/packages/desktop/src/providers/ip/variables.rs b/packages/desktop/src/providers/ip/variables.rs index 24e1e6e8..198be0b2 100644 --- a/packages/desktop/src/providers/ip/variables.rs +++ b/packages/desktop/src/providers/ip/variables.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct IpVariables { +pub struct IpOutput { pub address: String, pub approx_city: String, pub approx_country: String, diff --git a/packages/desktop/src/providers/komorebi/config.rs b/packages/desktop/src/providers/komorebi/config.rs index acdf8e83..1ee2e048 100644 --- a/packages/desktop/src/providers/komorebi/config.rs +++ b/packages/desktop/src/providers/komorebi/config.rs @@ -1,5 +1,5 @@ use serde::Deserialize; #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "komorebi")] +#[serde(rename_all = "camelCase")] pub struct KomorebiProviderConfig {} diff --git a/packages/desktop/src/providers/komorebi/provider.rs b/packages/desktop/src/providers/komorebi/provider.rs index f387f60c..c7364baf 100644 --- a/packages/desktop/src/providers/komorebi/provider.rs +++ b/packages/desktop/src/providers/komorebi/provider.rs @@ -1,46 +1,97 @@ use std::{ - io::{BufRead, BufReader}, - sync::Arc, + io::{BufReader, Read}, time::Duration, }; +use anyhow::Context; use async_trait::async_trait; -use komorebi_client::{Container, Monitor, Window, Workspace}; -use tokio::{ - sync::mpsc::Sender, - task::{self, AbortHandle}, +use komorebi_client::{ + Container, Monitor, SocketMessage, Window, Workspace, }; +use tokio::{sync::mpsc::Sender, time}; use tracing::debug; use super::{ KomorebiContainer, KomorebiLayout, KomorebiLayoutFlip, KomorebiMonitor, - KomorebiProviderConfig, KomorebiWindow, KomorebiWorkspace, -}; -use crate::providers::{ - komorebi::KomorebiVariables, - provider::Provider, - provider_ref::{ProviderOutput, VariablesResult}, - variables::ProviderVariables, + KomorebiOutput, KomorebiProviderConfig, KomorebiWindow, + KomorebiWorkspace, }; +use crate::providers::{Provider, ProviderOutput, ProviderResult}; const SOCKET_NAME: &str = "zebar.sock"; pub struct KomorebiProvider { - pub config: Arc, - abort_handle: Option, + _config: KomorebiProviderConfig, } impl KomorebiProvider { pub fn new(config: KomorebiProviderConfig) -> KomorebiProvider { - KomorebiProvider { - config: Arc::new(config), - abort_handle: None, + KomorebiProvider { _config: config } + } + + async fn create_socket( + &self, + emit_result_tx: Sender, + ) -> anyhow::Result<()> { + let socket = komorebi_client::subscribe(SOCKET_NAME) + .context("Failed to initialize Komorebi socket.")?; + + debug!("Connected to Komorebi socket."); + + for incoming in socket.incoming() { + debug!("Incoming Komorebi socket message."); + + match incoming { + Ok(stream) => { + let mut buffer = Vec::new(); + let mut reader = BufReader::new(stream); + + // Shutdown has been sent. + if matches!(reader.read_to_end(&mut buffer), Ok(0)) { + debug!("Komorebi shutdown."); + + // Attempt to reconnect to Komorebi. + while komorebi_client::send_message( + &SocketMessage::AddSubscriberSocket(SOCKET_NAME.to_string()), + ) + .is_err() + { + debug!("Attempting to reconnect to Komorebi."); + time::sleep(Duration::from_secs(15)).await; + } + } + + // Transform and emit the incoming Komorebi state. + if let Ok(notification) = + serde_json::from_str::( + &String::from_utf8(buffer).unwrap(), + ) + { + emit_result_tx + .send( + Ok(ProviderOutput::Komorebi(Self::transform_response( + notification.state, + ))) + .into(), + ) + .await; + } + } + Err(_) => { + emit_result_tx + .send( + Err(anyhow::anyhow!("Failed to read Komorebi stream.")) + .into(), + ) + .await; + } + } } + + Ok(()) } - fn transform_response( - state: komorebi_client::State, - ) -> KomorebiVariables { + fn transform_response(state: komorebi_client::State) -> KomorebiOutput { let all_monitors = state .monitors .elements() @@ -48,7 +99,7 @@ impl KomorebiProvider { .map(Self::transform_monitor) .collect(); - KomorebiVariables { + KomorebiOutput { all_monitors, focused_monitor_index: state.monitors.focused_idx(), } @@ -123,75 +174,9 @@ impl KomorebiProvider { #[async_trait] impl Provider for KomorebiProvider { - fn min_refresh_interval(&self) -> Option { - // State should always be up to date. - None - } - - async fn on_start( - &mut self, - config_hash: &str, - emit_output_tx: Sender, - ) { - let config_hash = config_hash.to_string(); - - let task_handle = task::spawn(async move { - let socket = komorebi_client::subscribe(SOCKET_NAME).unwrap(); - debug!("Connected to Komorebi socket."); - - for incoming in socket.incoming() { - debug!("Incoming Komorebi socket message."); - - match incoming { - Ok(data) => { - let reader = BufReader::new(data.try_clone().unwrap()); - - for line in reader.lines().flatten() { - if let Ok(notification) = serde_json::from_str::< - komorebi_client::Notification, - >(&line) - { - // Transform and emit the incoming Komorebi state. - _ = emit_output_tx - .send(ProviderOutput { - config_hash: config_hash.clone(), - variables: VariablesResult::Data( - ProviderVariables::Komorebi( - Self::transform_response(notification.state), - ), - ), - }) - .await; - } - } - } - Err(error) => { - _ = emit_output_tx - .send(ProviderOutput { - config_hash: config_hash.to_string(), - variables: VariablesResult::Error(error.to_string()), - }) - .await; - } - } - } - }); - - self.abort_handle = Some(task_handle.abort_handle()); - _ = task_handle.await; - } - - async fn on_refresh( - &mut self, - _config_hash: &str, - _emit_output_tx: Sender, - ) { - // No-op. - } - - async fn on_stop(&mut self) { - if let Some(handle) = &self.abort_handle { - handle.abort(); + async fn run(&self, emit_result_tx: Sender) { + if let Err(err) = self.create_socket(emit_result_tx.clone()).await { + emit_result_tx.send(Err(err).into()).await; } } } diff --git a/packages/desktop/src/providers/komorebi/variables.rs b/packages/desktop/src/providers/komorebi/variables.rs index 4780141f..d79fe9f0 100644 --- a/packages/desktop/src/providers/komorebi/variables.rs +++ b/packages/desktop/src/providers/komorebi/variables.rs @@ -3,7 +3,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct KomorebiVariables { +pub struct KomorebiOutput { pub all_monitors: Vec, pub focused_monitor_index: usize, } diff --git a/packages/desktop/src/providers/memory/config.rs b/packages/desktop/src/providers/memory/config.rs index de322925..2fa8b11e 100644 --- a/packages/desktop/src/providers/memory/config.rs +++ b/packages/desktop/src/providers/memory/config.rs @@ -1,11 +1,7 @@ use serde::Deserialize; -use crate::impl_interval_config; - #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "memory")] +#[serde(rename_all = "camelCase")] pub struct MemoryProviderConfig { pub refresh_interval: u64, } - -impl_interval_config!(MemoryProviderConfig); diff --git a/packages/desktop/src/providers/memory/provider.rs b/packages/desktop/src/providers/memory/provider.rs index 75dda699..8554590b 100644 --- a/packages/desktop/src/providers/memory/provider.rs +++ b/packages/desktop/src/providers/memory/provider.rs @@ -1,17 +1,13 @@ use std::sync::Arc; -use async_trait::async_trait; use sysinfo::System; -use tokio::{sync::Mutex, task::AbortHandle}; +use tokio::sync::Mutex; -use super::{MemoryProviderConfig, MemoryVariables}; -use crate::providers::{ - provider::IntervalProvider, variables::ProviderVariables, -}; +use super::{MemoryOutput, MemoryProviderConfig}; +use crate::{impl_interval_provider, providers::ProviderOutput}; pub struct MemoryProvider { - pub config: Arc, - abort_handle: Option, + config: MemoryProviderConfig, sysinfo: Arc>, } @@ -20,47 +16,22 @@ impl MemoryProvider { config: MemoryProviderConfig, sysinfo: Arc>, ) -> MemoryProvider { - MemoryProvider { - config: Arc::new(config), - abort_handle: None, - sysinfo, - } + MemoryProvider { config, sysinfo } } -} - -#[async_trait] -impl IntervalProvider for MemoryProvider { - type Config = MemoryProviderConfig; - type State = Mutex; - fn config(&self) -> Arc { - self.config.clone() + fn refresh_interval_ms(&self) -> u64 { + self.config.refresh_interval } - fn state(&self) -> Arc> { - self.sysinfo.clone() - } - - fn abort_handle(&self) -> &Option { - &self.abort_handle - } - - fn set_abort_handle(&mut self, abort_handle: AbortHandle) { - self.abort_handle = Some(abort_handle) - } - - async fn get_refreshed_variables( - _: &MemoryProviderConfig, - sysinfo: &Mutex, - ) -> anyhow::Result { - let mut sysinfo = sysinfo.lock().await; + async fn run_interval(&self) -> anyhow::Result { + let mut sysinfo = self.sysinfo.lock().await; sysinfo.refresh_memory(); let usage = (sysinfo.used_memory() as f32 / sysinfo.total_memory() as f32) * 100.0; - Ok(ProviderVariables::Memory(MemoryVariables { + Ok(ProviderOutput::Memory(MemoryOutput { usage, free_memory: sysinfo.free_memory(), used_memory: sysinfo.used_memory(), @@ -71,3 +42,5 @@ impl IntervalProvider for MemoryProvider { })) } } + +impl_interval_provider!(MemoryProvider); diff --git a/packages/desktop/src/providers/memory/variables.rs b/packages/desktop/src/providers/memory/variables.rs index f940b0eb..21b121ee 100644 --- a/packages/desktop/src/providers/memory/variables.rs +++ b/packages/desktop/src/providers/memory/variables.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct MemoryVariables { +pub struct MemoryOutput { pub usage: f32, pub free_memory: u64, pub used_memory: u64, diff --git a/packages/desktop/src/providers/mod.rs b/packages/desktop/src/providers/mod.rs index b902a359..83eba865 100644 --- a/packages/desktop/src/providers/mod.rs +++ b/packages/desktop/src/providers/mod.rs @@ -1,14 +1,20 @@ -pub mod battery; -pub mod config; -pub mod cpu; -pub mod host; -pub mod ip; +mod battery; +mod config; +mod cpu; +mod host; +mod ip; #[cfg(windows)] -pub mod komorebi; -pub mod memory; -pub mod network; -pub mod provider; -pub mod provider_manager; -pub mod provider_ref; -pub mod variables; -pub mod weather; +mod komorebi; +mod memory; +mod network; +mod provider; +mod provider_manager; +mod provider_ref; +mod variables; +mod weather; + +pub use config::*; +pub use provider::*; +pub use provider_manager::*; +pub use provider_ref::*; +pub use variables::*; diff --git a/packages/desktop/src/providers/network/config.rs b/packages/desktop/src/providers/network/config.rs index 1bd7b0fd..b1eda959 100644 --- a/packages/desktop/src/providers/network/config.rs +++ b/packages/desktop/src/providers/network/config.rs @@ -1,11 +1,7 @@ use serde::Deserialize; -use crate::impl_interval_config; - #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "network")] +#[serde(rename_all = "camelCase")] pub struct NetworkProviderConfig { pub refresh_interval: u64, } - -impl_interval_config!(NetworkProviderConfig); diff --git a/packages/desktop/src/providers/network/provider.rs b/packages/desktop/src/providers/network/provider.rs index 480cd20f..2e70e113 100644 --- a/packages/desktop/src/providers/network/provider.rs +++ b/packages/desktop/src/providers/network/provider.rs @@ -1,23 +1,23 @@ use std::sync::Arc; -use async_trait::async_trait; +use anyhow::Context; use netdev::interface::get_interfaces; use sysinfo::Networks; -use tokio::{sync::Mutex, task::AbortHandle}; +use tokio::sync::Mutex; use super::{ wifi_hotspot::{default_gateway_wifi, WifiHotstop}, - InterfaceType, NetworkGateway, NetworkInterface, NetworkProviderConfig, - NetworkTraffic, NetworkVariables, + InterfaceType, NetworkGateway, NetworkInterface, NetworkOutput, + NetworkProviderConfig, NetworkTraffic, NetworkTrafficMeasure, }; -use crate::providers::{ - provider::IntervalProvider, variables::ProviderVariables, +use crate::{ + common::{to_iec_bytes, to_si_bytes}, + impl_interval_provider, + providers::ProviderOutput, }; pub struct NetworkProvider { - pub config: Arc, - abort_handle: Option, - _state: Arc>, + config: NetworkProviderConfig, netinfo: Arc>, } @@ -26,14 +26,94 @@ impl NetworkProvider { config: NetworkProviderConfig, netinfo: Arc>, ) -> NetworkProvider { - NetworkProvider { - config: Arc::new(config), - abort_handle: None, - _state: Arc::new(Mutex::new(())), - netinfo, + NetworkProvider { config, netinfo } + } + + fn refresh_interval_ms(&self) -> u64 { + self.config.refresh_interval + } + + async fn run_interval(&self) -> anyhow::Result { + let mut netinfo = self.netinfo.lock().await; + netinfo.refresh(); + + let interfaces = get_interfaces(); + let default_interface = netdev::get_default_interface().ok(); + + let received_per_sec = Self::total_bytes_received(&netinfo) + / self.config.refresh_interval + * 1000; + + let transmitted_per_sec = Self::total_bytes_transmitted(&netinfo) + / self.config.refresh_interval + * 1000; + + Ok(ProviderOutput::Network(NetworkOutput { + default_interface: default_interface + .as_ref() + .map(Self::transform_interface), + default_gateway: default_interface + .and_then(|interface| interface.gateway) + .and_then(|gateway| { + default_gateway_wifi() + .map(|wifi| Self::transform_gateway(&gateway, wifi)) + .ok() + }), + interfaces: interfaces + .iter() + .map(Self::transform_interface) + .collect(), + traffic: NetworkTraffic { + received: Self::to_network_traffic_measure(received_per_sec)?, + transmitted: Self::to_network_traffic_measure( + transmitted_per_sec, + )?, + }, + })) + } + + fn to_network_traffic_measure( + bytes: u64, + ) -> anyhow::Result { + let (si_value, si_unit) = to_si_bytes(bytes as f64); + let (iec_value, iec_unit) = to_iec_bytes(bytes as f64); + + Ok(NetworkTrafficMeasure { + bytes, + si_value, + si_unit, + iec_value, + iec_unit, + }) + } + + /// Gets the total network (down) usage. + /// + /// Returns the total bytes received by every network interface. + fn total_bytes_received(networks: &sysinfo::Networks) -> u64 { + let mut received_total: Vec = Vec::new(); + + for (_interface_name, network) in networks { + received_total.push(network.received()); + } + + received_total.iter().sum() + } + + /// Gets the total network (up) usage. + /// + /// Returns the total bytes transmitted by every network interface. + fn total_bytes_transmitted(networks: &sysinfo::Networks) -> u64 { + let mut transmitted_total: Vec = Vec::new(); + + for (_interface_name, network) in networks { + transmitted_total.push(network.transmitted()); } + + transmitted_total.iter().sum() } + /// Transforms a `netdev::Interface` into a `NetworkInterface`. fn transform_interface( interface: &netdev::Interface, ) -> NetworkInterface { @@ -64,6 +144,7 @@ impl NetworkProvider { } } + /// Transforms a `netdev::NetworkDevice` into a `NetworkGateway`. fn transform_gateway( gateway: &netdev::NetworkDevice, wifi_hotspot: WifiHotstop, @@ -86,91 +167,4 @@ impl NetworkProvider { } } -#[async_trait] -impl IntervalProvider for NetworkProvider { - type Config = NetworkProviderConfig; - type State = Mutex; - - fn config(&self) -> Arc { - self.config.clone() - } - - fn state(&self) -> Arc> { - self.netinfo.clone() - } - - fn abort_handle(&self) -> &Option { - &self.abort_handle - } - - fn set_abort_handle(&mut self, abort_handle: AbortHandle) { - self.abort_handle = Some(abort_handle) - } - - async fn get_refreshed_variables( - config: &NetworkProviderConfig, - netinfo: &Mutex, - ) -> anyhow::Result { - let mut netinfo = netinfo.lock().await; - netinfo.refresh(); - - let interfaces = get_interfaces(); - - let default_interface = netdev::get_default_interface().ok(); - - let variables = NetworkVariables { - default_interface: default_interface - .as_ref() - .map(Self::transform_interface), - default_gateway: default_interface - .and_then(|interface| interface.gateway) - .and_then(|gateway| { - default_gateway_wifi() - .map(|wifi| Self::transform_gateway(&gateway, wifi)) - .ok() - }), - interfaces: interfaces - .iter() - .map(Self::transform_interface) - .collect(), - traffic: NetworkTraffic { - received: to_bytes_per_seconds( - get_network_down(&netinfo), - config.refresh_interval, - ), - transmitted: to_bytes_per_seconds( - get_network_up(&netinfo), - config.refresh_interval, - ), - }, - }; - - Ok(ProviderVariables::Network(variables)) - } -} - -// Get the total network (down) usage -fn get_network_down(req_net: &sysinfo::Networks) -> u64 { - // Get the total bytes recieved by every network interface - let mut received_total: Vec = Vec::new(); - for (_interface_name, network) in req_net { - received_total.push(network.received() as u64); - } - - received_total.iter().sum() -} - -// Get the total network (up) usage -fn get_network_up(req_net: &sysinfo::Networks) -> u64 { - // Get the total bytes recieved by every network interface - let mut transmitted_total: Vec = Vec::new(); - for (_interface_name, network) in req_net { - transmitted_total.push(network.transmitted() as u64); - } - - transmitted_total.iter().sum() -} - -fn to_bytes_per_seconds(input_in_bytes: u64, timespan_in_ms: u64) -> u64 { - input_in_bytes / (timespan_in_ms / 1000) -} +impl_interval_provider!(NetworkProvider); diff --git a/packages/desktop/src/providers/network/variables.rs b/packages/desktop/src/providers/network/variables.rs index 8e287fe1..46979e6f 100644 --- a/packages/desktop/src/providers/network/variables.rs +++ b/packages/desktop/src/providers/network/variables.rs @@ -3,7 +3,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct NetworkVariables { +pub struct NetworkOutput { pub default_interface: Option, pub default_gateway: Option, pub interfaces: Vec, @@ -13,8 +13,18 @@ pub struct NetworkVariables { #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct NetworkTraffic { - pub received: u64, - pub transmitted: u64, + pub received: NetworkTrafficMeasure, + pub transmitted: NetworkTrafficMeasure, +} + +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NetworkTrafficMeasure { + pub bytes: u64, + pub si_value: f64, + pub si_unit: String, + pub iec_value: f64, + pub iec_unit: String, } #[derive(Serialize, Debug, Clone)] diff --git a/packages/desktop/src/providers/network/wifi_hotspot.rs b/packages/desktop/src/providers/network/wifi_hotspot.rs index 673e2742..05307557 100644 --- a/packages/desktop/src/providers/network/wifi_hotspot.rs +++ b/packages/desktop/src/providers/network/wifi_hotspot.rs @@ -1,7 +1,11 @@ +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::process::Command; use anyhow::Context; use regex::Regex; +#[cfg(target_os = "windows")] +use windows::Win32::System::Threading::CREATE_NO_WINDOW; #[derive(Debug)] pub struct WifiHotstop { @@ -15,39 +19,51 @@ pub struct WifiHotstop { /// Unclear if the primary interface is always the default interface /// returned by the netdev/defaultnet crate - requires more testing. pub fn default_gateway_wifi() -> anyhow::Result { - if cfg!(not(target_os = "windows")) { - return Ok(WifiHotstop { + #[cfg(not(target_os = "windows"))] + { + Ok(WifiHotstop { ssid: None, signal_strength: None, - }); + }) } + #[cfg(target_os = "windows")] + { + let ssid_match = Regex::new(r"(?m)^\s*SSID\s*:\s*(.*?)\r?$").unwrap(); - let ssid_match = Regex::new(r"(?m)^\s*SSID\s*:\s*(.*?)\r?$").unwrap(); + let signal_match = + Regex::new(r"(?m)^\s*Signal\s*:\s*(.*?)\r?$").unwrap(); - let signal_match = - Regex::new(r"(?m)^\s*Signal\s*:\s*(.*?)\r?$").unwrap(); + let signal_strip = Regex::new(r"(\d+)%").unwrap(); - let signal_strip = Regex::new(r"(\d+)%").unwrap(); + let output = Command::new("netsh") + .args(&["wlan", "show", "interfaces"]) + .creation_flags(CREATE_NO_WINDOW.0) + .output() + .context("Could not run netsh.")?; - let output = Command::new("netsh") - .args(&["wlan", "show", "interfaces"]) - .output() - .context("Could not run netsh.")?; + let output = String::from_utf8_lossy(&output.stdout); - let output = String::from_utf8_lossy(&output.stdout); + let ssid = ssid_match + .captures(&output) + .context("Failed to parse WiFi hotspot SSID.")? + .get(1) + .map(|s| s.as_str().to_string()); - let ssid = ssid_match - .captures(&output) - .map(|m| m.get(1).unwrap().as_str().to_string()); - let signal_str: Option<&str> = signal_match - .captures(&output) - .map(|m| m.get(1).unwrap().as_str()); - let signal: Option = signal_str - .and_then(|s| signal_strip.captures(s)) - .map(|m| m.get(1).unwrap().as_str().parse().unwrap()); + let signal_str = signal_match + .captures(&output) + .context("Failed to parse WiFi hotspot signal strength.")? + .get(1) + .map(|s| s.as_str()); - return Ok(WifiHotstop { - ssid, - signal_strength: signal, - }); + let signal = signal_str + .and_then(|s| signal_strip.captures(s)) + .context("Failed to parse WiFi hotspot signal strength.")? + .get(1) + .and_then(|s| s.as_str().parse().ok()); + + Ok(WifiHotstop { + ssid, + signal_strength: signal, + }) + } } diff --git a/packages/desktop/src/providers/provider.rs b/packages/desktop/src/providers/provider.rs index 3f4ca313..f751662a 100644 --- a/packages/desktop/src/providers/provider.rs +++ b/packages/desktop/src/providers/provider.rs @@ -1,139 +1,53 @@ -use std::{sync::Arc, time::Duration}; - use async_trait::async_trait; -use tokio::{ - sync::mpsc::Sender, - task::{self, AbortHandle}, - time, -}; +use tokio::sync::mpsc::Sender; -use super::{provider_ref::ProviderOutput, variables::ProviderVariables}; +use super::ProviderResult; #[async_trait] -pub trait Provider { +pub trait Provider: Send + Sync { /// Callback for when the provider is started. - async fn on_start( - &mut self, - config_hash: &str, - emit_output_tx: Sender, - ); - - /// Callback for when the provider is refreshed. - async fn on_refresh( - &mut self, - config_hash: &str, - emit_output_tx: Sender, - ); + async fn run(&self, emit_result_tx: Sender); /// Callback for when the provider is stopped. - async fn on_stop(&mut self); - - /// Minimum interval between refreshes. - /// - /// Affects how the provider output is cached. - fn min_refresh_interval(&self) -> Option; -} - -#[async_trait] -pub trait IntervalProvider { - type Config: Sync + Send + 'static + IntervalConfig; - type State: Sync + Send + 'static; - - /// Default to 2 seconds as the minimum refresh interval. - fn min_refresh_interval(&self) -> Option { - Some(Duration::from_secs(2)) + async fn on_stop(&self) { + // No-op by default. } - - fn config(&self) -> Arc; - - fn state(&self) -> Arc; - - fn abort_handle(&self) -> &Option; - - fn set_abort_handle(&mut self, abort_handle: AbortHandle); - - async fn get_refreshed_variables( - config: &Self::Config, - state: &Self::State, - ) -> anyhow::Result; -} - -#[async_trait] -impl Provider for T { - fn min_refresh_interval(&self) -> Option { - T::min_refresh_interval(self) - } - - async fn on_start( - &mut self, - config_hash: &str, - emit_output_tx: Sender, - ) { - let config = self.config(); - let state = self.state(); - let config_hash = config_hash.to_string(); - - let interval_task = task::spawn(async move { - let mut interval = - time::interval(Duration::from_millis(config.refresh_interval())); - - loop { - // The first tick fires immediately. - interval.tick().await; - - _ = emit_output_tx - .send(ProviderOutput { - config_hash: config_hash.clone(), - variables: T::get_refreshed_variables(&config, &state) - .await - .into(), - }) - .await; - } - }); - - self.set_abort_handle(interval_task.abort_handle()); - _ = interval_task.await; - } - - async fn on_refresh( - &mut self, - config_hash: &str, - emit_output_tx: Sender, - ) { - _ = emit_output_tx - .send(ProviderOutput { - config_hash: config_hash.to_string(), - variables: T::get_refreshed_variables( - &self.config(), - &self.state(), - ) - .await - .into(), - }) - .await; - } - - async fn on_stop(&mut self) { - if let Some(handle) = &self.abort_handle() { - handle.abort(); - } - } -} - -/// Require interval providers to have a refresh interval in their config. -pub trait IntervalConfig { - fn refresh_interval(&self) -> u64; } +/// Implements the `Provider` trait for the given struct. +/// +/// Expects that the struct has a `refresh_interval_ms` and `run_interval` +/// method. #[macro_export] -macro_rules! impl_interval_config { - ($struct_name:ident) => { - use crate::providers::provider::IntervalConfig; - - impl IntervalConfig for $struct_name { - fn refresh_interval(&self) -> u64 { - self.refresh_interval +macro_rules! impl_interval_provider { + ($type:ty) => { + #[async_trait::async_trait] + impl crate::providers::Provider for $type { + async fn run( + &self, + emit_result_tx: tokio::sync::mpsc::Sender< + crate::providers::ProviderResult, + >, + ) { + let mut interval = tokio::time::interval( + std::time::Duration::from_millis(self.refresh_interval_ms()), + ); + + // Skip missed ticks when the interval runs. This prevents a burst + // of backlogged ticks after a delay. + interval + .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + + let res = + emit_result_tx.send(self.run_interval().await.into()).await; + + if let Err(err) = res { + tracing::error!("Error sending provider result: {:?}", err); + } + } } } }; diff --git a/packages/desktop/src/providers/provider_manager.rs b/packages/desktop/src/providers/provider_manager.rs index ff815179..1f7069f8 100644 --- a/packages/desktop/src/providers/provider_manager.rs +++ b/packages/desktop/src/providers/provider_manager.rs @@ -1,22 +1,14 @@ use std::{collections::HashMap, sync::Arc}; use sysinfo::{Networks, System}; -use tauri::{AppHandle, Emitter, Runtime}; -use tokio::{ - sync::{ - mpsc::{self}, - Mutex, - }, - task, -}; -use tracing::{info, warn}; +use tauri::AppHandle; +use tokio::sync::Mutex; +use tracing::warn; -use super::{ - config::ProviderConfig, - provider_ref::{ProviderOutput, ProviderRef}, -}; +use super::{ProviderConfig, ProviderRef}; /// State shared between providers. +#[derive(Clone)] pub struct SharedProviderState { pub sysinfo: Arc>, pub netinfo: Arc>, @@ -24,20 +16,15 @@ pub struct SharedProviderState { /// Manages the creation and cleanup of providers. pub struct ProviderManager { - emit_output_tx: mpsc::Sender, - emit_output_rx: Option>, + app_handle: AppHandle, providers: Arc>>, shared_state: SharedProviderState, } impl ProviderManager { - pub fn new() -> Self { - let (emit_output_tx, emit_output_rx) = - mpsc::channel::(1); - + pub fn new(app_handle: &AppHandle) -> Self { Self { - emit_output_tx, - emit_output_rx: Some(emit_output_rx), + app_handle: app_handle.clone(), providers: Arc::new(Mutex::new(HashMap::new())), shared_state: SharedProviderState { sysinfo: Arc::new(Mutex::new(System::new_all())), @@ -46,38 +33,6 @@ impl ProviderManager { } } - /// Starts listening for provider outputs and emits them to frontend - /// clients. - pub fn init(&mut self, app_handle: &AppHandle) { - let mut emit_output_rx = self.emit_output_rx.take().unwrap(); - let providers = self.providers.clone(); - let app_handle = app_handle.clone(); - - task::spawn(async move { - while let Some(output) = emit_output_rx.recv().await { - info!("Emitting for provider: {}", output.config_hash); - - let output = Box::new(output); - - if let Err(err) = app_handle.emit("provider-emit", output.clone()) - { - warn!("Error emitting provider output: {:?}", err); - } - - // Update the provider's output cache. - if let Ok(mut providers) = providers.try_lock() { - if let Some(found_provider) = - providers.get_mut(&output.config_hash) - { - found_provider.update_cache(output); - } - } else { - warn!("Failed to update provider output cache."); - } - } - }); - } - /// Creates a provider with the given config. pub async fn create( &self, @@ -85,25 +40,27 @@ impl ProviderManager { config: ProviderConfig, _tracked_access: Vec, ) -> anyhow::Result<()> { - let found_provider = - { self.providers.lock().await.get(&config_hash).cloned() }; - - // If a provider with the given config already exists, refresh it - // and return early. - if let Some(found_provider) = found_provider { - if let Err(err) = found_provider.refresh().await { - warn!("Error refreshing provider: {:?}", err); - } + { + let mut providers = self.providers.lock().await; + + // If a provider with the given config already exists, refresh it + // and return early. + if let Some(found_provider) = providers.get_mut(&config_hash) { + if let Err(err) = found_provider.refresh().await { + warn!("Error refreshing provider: {:?}", err); + } - return Ok(()); - }; + return Ok(()); + }; + } let provider_ref = ProviderRef::new( - config_hash.clone(), + &self.app_handle, config, - self.emit_output_tx.clone(), - &self.shared_state, - )?; + config_hash.clone(), + self.shared_state.clone(), + ) + .await?; let mut providers = self.providers.lock().await; providers.insert(config_hash, provider_ref); diff --git a/packages/desktop/src/providers/provider_ref.rs b/packages/desktop/src/providers/provider_ref.rs index ec4c68c0..105ce2d0 100644 --- a/packages/desktop/src/providers/provider_ref.rs +++ b/packages/desktop/src/providers/provider_ref.rs @@ -1,159 +1,173 @@ -use std::time::{Duration, Instant}; +use std::sync::Arc; use anyhow::bail; use serde::Serialize; -use tokio::{sync::mpsc, task}; -use tracing::info; +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use tokio::{ + sync::{mpsc, Mutex}, + task, +}; +use tracing::{info, warn}; #[cfg(windows)] use super::komorebi::KomorebiProvider; use super::{ - battery::BatteryProvider, config::ProviderConfig, cpu::CpuProvider, - host::HostProvider, ip::IpProvider, memory::MemoryProvider, - network::NetworkProvider, provider::Provider, - provider_manager::SharedProviderState, variables::ProviderVariables, - weather::WeatherProvider, + battery::BatteryProvider, cpu::CpuProvider, host::HostProvider, + ip::IpProvider, memory::MemoryProvider, network::NetworkProvider, + weather::WeatherProvider, Provider, ProviderConfig, ProviderOutput, + SharedProviderState, }; /// Reference to an active provider. -#[derive(Debug, Clone)] pub struct ProviderRef { - pub config_hash: String, - pub min_refresh_interval: Option, - pub cache: Option, - pub emit_output_tx: mpsc::Sender, - pub refresh_tx: mpsc::Sender<()>, - pub stop_tx: mpsc::Sender<()>, -} + /// Cache for provider output. + cache: Arc>>>, -#[derive(Debug, Clone)] -pub struct ProviderCache { - pub timestamp: Instant, - pub output: Box, -} + /// Sender channel for emitting provider output/error to frontend + /// clients. + emit_result_tx: mpsc::Sender, -/// Output emitted to frontend clients. -#[derive(Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ProviderOutput { - pub config_hash: String, - pub variables: VariablesResult, + /// Sender channel for stopping the provider. + stop_tx: mpsc::Sender<()>, } -/// Provider variable output emitted to frontend clients. +/// Provider output/error emitted to frontend clients. /// -/// This is used instead of a normal `Result` type to serialize it in a -/// nicer way. +/// This is used instead of a normal `Result` type in order to serialize it +/// in a nicer way. #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub enum VariablesResult { - Data(ProviderVariables), +pub enum ProviderResult { + Output(ProviderOutput), Error(String), } -/// Implements conversion from an `anyhow::Result`. -impl From> for VariablesResult { - fn from(result: anyhow::Result) -> Self { +/// Implements conversion from `anyhow::Result`. +impl From> for ProviderResult { + fn from(result: anyhow::Result) -> Self { match result { - Ok(data) => VariablesResult::Data(data), - Err(err) => VariablesResult::Error(err.to_string()), + Ok(output) => ProviderResult::Output(output), + Err(err) => ProviderResult::Error(err.to_string()), } } } impl ProviderRef { - pub fn new( - config_hash: String, + /// Creates a new `ProviderRef` instance. + pub async fn new( + app_handle: &AppHandle, config: ProviderConfig, - emit_output_tx: mpsc::Sender, - shared_state: &SharedProviderState, + config_hash: String, + shared_state: SharedProviderState, ) -> anyhow::Result { - let provider = Self::create_provider(config, shared_state)?; + let cache = Arc::new(Mutex::new(None)); - let (refresh_tx, refresh_rx) = mpsc::channel::<()>(1); let (stop_tx, stop_rx) = mpsc::channel::<()>(1); - - let min_refresh_interval = provider.min_refresh_interval(); - let config_hash_clone = config_hash.clone(); - let emit_output_tx_clone = emit_output_tx.clone(); - - task::spawn(async move { - Self::start_provider( - provider, - config_hash_clone, - emit_output_tx_clone, - refresh_rx, - stop_rx, - ) - .await; - }); + let (emit_result_tx, emit_result_rx) = + mpsc::channel::(1); + + Self::start_output_listener( + app_handle.clone(), + config_hash.clone(), + cache.clone(), + emit_result_rx, + ); + + Self::start_provider( + config, + config_hash, + shared_state, + emit_result_tx.clone(), + stop_rx, + )?; Ok(Self { - config_hash, - min_refresh_interval, - cache: None, - emit_output_tx, - refresh_tx, + cache, + emit_result_tx, stop_tx, }) } - /// Starts the provider. - async fn start_provider( - mut provider: Box, + fn start_output_listener( + app_handle: AppHandle, config_hash: String, - emit_output_tx: mpsc::Sender, - mut refresh_rx: mpsc::Receiver<()>, - mut stop_rx: mpsc::Receiver<()>, + cache: Arc>>>, + mut emit_result_rx: mpsc::Receiver, ) { - let mut has_started = false; - - // Loop to avoid exiting the select on refresh. - loop { - let config_hash = config_hash.clone(); - let emit_output_tx = emit_output_tx.clone(); - - tokio::select! { - // Default match arm which handles initialization of the provider. - // This has a precondition to avoid running again on refresh. - _ = { - info!("Starting provider: {}", config_hash); - has_started = true; - provider.on_start(&config_hash, emit_output_tx.clone()) - }, if !has_started => break, - - // On refresh, re-emit provider variables and continue looping. - Some(_) = refresh_rx.recv() => { - info!("Refreshing provider: {}", config_hash); - _ = provider.on_refresh(&config_hash, emit_output_tx).await; - }, - - // On stop, perform any necessary clean up and exit the loop. - Some(_) = stop_rx.recv() => { - info!("Stopping provider: {}", config_hash); - _ = provider.on_stop().await; - break; - }, + task::spawn(async move { + while let Some(output) = emit_result_rx.recv().await { + info!("Emitting for provider: {}", config_hash); + + let output = Box::new(output); + let payload = json!({ + "configHash": config_hash.clone(), + "result": *output.clone(), + }); + + if let Err(err) = app_handle.emit("provider-emit", payload) { + warn!("Error emitting provider output: {:?}", err); + } + + // Update the provider's output cache. + if let Ok(mut providers) = cache.try_lock() { + *providers = Some(output); + } else { + warn!("Failed to update provider output cache."); + } } - } + }); + } - info!("Provider stopped: {}", config_hash); + /// Starts the provider in a separate task. + fn start_provider( + config: ProviderConfig, + config_hash: String, + shared_state: SharedProviderState, + emit_result_tx: mpsc::Sender, + mut stop_rx: mpsc::Receiver<()>, + ) -> anyhow::Result<()> { + let provider = Self::create_provider(config, shared_state)?; + + task::spawn(async move { + // TODO: Add arc `should_stop` to be passed to `run`. + + let run = provider.run(emit_result_tx); + tokio::pin!(run); + + // Ref: https://tokio.rs/tokio/tutorial/select#resuming-an-async-operation + loop { + tokio::select! { + // Default match arm which continuously runs the provider. + _ = run => break, + + // On stop, perform any necessary clean up and exit the loop. + Some(_) = stop_rx.recv() => { + info!("Stopping provider: {}", config_hash); + _ = provider.on_stop().await; + break; + }, + } + } + + info!("Provider stopped: {}", config_hash); + }); + + Ok(()) } fn create_provider( config: ProviderConfig, - shared_state: &SharedProviderState, - ) -> anyhow::Result> { - let provider: Box = match config { + shared_state: SharedProviderState, + ) -> anyhow::Result> { + let provider: Box = match config { ProviderConfig::Battery(config) => { - Box::new(BatteryProvider::new(config)?) + Box::new(BatteryProvider::new(config)) } ProviderConfig::Cpu(config) => { Box::new(CpuProvider::new(config, shared_state.sysinfo.clone())) } - ProviderConfig::Host(config) => { - Box::new(HostProvider::new(config, shared_state.sysinfo.clone())) - } + ProviderConfig::Host(config) => Box::new(HostProvider::new(config)), ProviderConfig::Ip(config) => Box::new(IpProvider::new(config)), #[cfg(windows)] ProviderConfig::Komorebi(config) => { @@ -176,28 +190,16 @@ impl ProviderRef { Ok(provider) } - /// Updates cache with the given output. - pub fn update_cache(&mut self, output: Box) { - self.cache = Some(ProviderCache { - timestamp: Instant::now(), - output, - }); - } - - /// Refreshes the provider. + /// Re-emits the latest provider output. /// - /// Since the previous output of providers is cached, if within the - /// minimum refresh interval, send the previous output. + /// No-ops if the provider hasn't outputted yet, since the provider will + /// anyways emit its output after initialization. pub async fn refresh(&self) -> anyhow::Result<()> { - let min_refresh_interval = - self.min_refresh_interval.unwrap_or(Duration::MAX); + let cache = { self.cache.lock().await.clone() }; - match &self.cache { - Some(cache) if cache.timestamp.elapsed() >= min_refresh_interval => { - self.emit_output_tx.send(*cache.output.clone()).await?; - } - _ => self.refresh_tx.send(()).await?, - }; + if let Some(cache) = cache { + self.emit_result_tx.send(*cache).await?; + } Ok(()) } diff --git a/packages/desktop/src/providers/variables.rs b/packages/desktop/src/providers/variables.rs index e6337f92..3f7ccea3 100644 --- a/packages/desktop/src/providers/variables.rs +++ b/packages/desktop/src/providers/variables.rs @@ -1,23 +1,22 @@ use serde::Serialize; #[cfg(windows)] -use super::komorebi::KomorebiVariables; +use super::komorebi::KomorebiOutput; use super::{ - battery::BatteryVariables, cpu::CpuVariables, host::HostVariables, - ip::IpVariables, memory::MemoryVariables, network::NetworkVariables, - weather::WeatherVariables, + battery::BatteryOutput, cpu::CpuOutput, host::HostOutput, ip::IpOutput, + memory::MemoryOutput, network::NetworkOutput, weather::WeatherOutput, }; #[derive(Serialize, Debug, Clone)] #[serde(untagged)] -pub enum ProviderVariables { - Battery(BatteryVariables), - Cpu(CpuVariables), - Host(HostVariables), - Ip(IpVariables), +pub enum ProviderOutput { + Battery(BatteryOutput), + Cpu(CpuOutput), + Host(HostOutput), + Ip(IpOutput), #[cfg(windows)] - Komorebi(KomorebiVariables), - Memory(MemoryVariables), - Network(NetworkVariables), - Weather(WeatherVariables), + Komorebi(KomorebiOutput), + Memory(MemoryOutput), + Network(NetworkOutput), + Weather(WeatherOutput), } diff --git a/packages/desktop/src/providers/weather/config.rs b/packages/desktop/src/providers/weather/config.rs index 5c31026d..bc24a2d3 100644 --- a/packages/desktop/src/providers/weather/config.rs +++ b/packages/desktop/src/providers/weather/config.rs @@ -1,13 +1,9 @@ use serde::Deserialize; -use crate::impl_interval_config; - #[derive(Deserialize, Debug)] -#[serde(tag = "type", rename = "weather")] +#[serde(rename_all = "camelCase")] pub struct WeatherProviderConfig { pub refresh_interval: u64, pub latitude: f32, pub longitude: f32, } - -impl_interval_config!(WeatherProviderConfig); diff --git a/packages/desktop/src/providers/weather/provider.rs b/packages/desktop/src/providers/weather/provider.rs index c5e7e553..f3c6b305 100644 --- a/packages/desktop/src/providers/weather/provider.rs +++ b/packages/desktop/src/providers/weather/provider.rs @@ -1,32 +1,62 @@ -use std::sync::Arc; - -use async_trait::async_trait; use reqwest::Client; -use tokio::task::AbortHandle; use super::{ - open_meteo_res::OpenMeteoRes, WeatherProviderConfig, WeatherStatus, - WeatherVariables, -}; -use crate::providers::{ - provider::IntervalProvider, variables::ProviderVariables, + open_meteo_res::OpenMeteoRes, WeatherOutput, WeatherProviderConfig, + WeatherStatus, }; +use crate::{impl_interval_provider, providers::ProviderOutput}; pub struct WeatherProvider { - pub config: Arc, - abort_handle: Option, - http_client: Arc, + config: WeatherProviderConfig, + http_client: Client, } impl WeatherProvider { pub fn new(config: WeatherProviderConfig) -> WeatherProvider { WeatherProvider { - config: Arc::new(config), - abort_handle: None, - http_client: Arc::new(Client::new()), + config, + http_client: Client::new(), } } + fn refresh_interval_ms(&self) -> u64 { + self.config.refresh_interval + } + + async fn run_interval(&self) -> anyhow::Result { + let res = self + .http_client + .get("https://api.open-meteo.com/v1/forecast") + .query(&[ + ("temperature_unit", "celsius"), + ("latitude", &self.config.latitude.to_string()), + ("longitude", &self.config.longitude.to_string()), + ("current_weather", "true"), + ("daily", "sunset,sunrise"), + ("timezone", "auto"), + ]) + .send() + .await? + .json::() + .await?; + + let current_weather = res.current_weather; + let is_daytime = current_weather.is_day == 1; + + Ok(ProviderOutput::Weather(WeatherOutput { + is_daytime, + status: Self::get_weather_status( + current_weather.weather_code, + is_daytime, + ), + celsius_temp: current_weather.temperature, + fahrenheit_temp: Self::celsius_to_fahrenheit( + current_weather.temperature, + ), + wind_speed: current_weather.wind_speed, + })) + } + fn celsius_to_fahrenheit(celsius_temp: f32) -> f32 { return (celsius_temp * 9.) / 5. + 32.; } @@ -70,60 +100,4 @@ impl WeatherProvider { } } -#[async_trait] -impl IntervalProvider for WeatherProvider { - type Config = WeatherProviderConfig; - type State = Client; - - fn config(&self) -> Arc { - self.config.clone() - } - - fn state(&self) -> Arc { - self.http_client.clone() - } - - fn abort_handle(&self) -> &Option { - &self.abort_handle - } - - fn set_abort_handle(&mut self, abort_handle: AbortHandle) { - self.abort_handle = Some(abort_handle) - } - - async fn get_refreshed_variables( - config: &WeatherProviderConfig, - http_client: &Client, - ) -> anyhow::Result { - let res = http_client - .get("https://api.open-meteo.com/v1/forecast") - .query(&[ - ("temperature_unit", "celsius"), - ("latitude", &config.latitude.to_string()), - ("longitude", &config.longitude.to_string()), - ("current_weather", "true"), - ("daily", "sunset,sunrise"), - ("timezone", "auto"), - ]) - .send() - .await? - .json::() - .await?; - - let current_weather = res.current_weather; - let is_daytime = current_weather.is_day == 1; - - Ok(ProviderVariables::Weather(WeatherVariables { - is_daytime, - status: Self::get_weather_status( - current_weather.weather_code, - is_daytime, - ), - celsius_temp: current_weather.temperature, - fahrenheit_temp: Self::celsius_to_fahrenheit( - current_weather.temperature, - ), - wind_speed: current_weather.wind_speed, - })) - } -} +impl_interval_provider!(WeatherProvider); diff --git a/packages/desktop/src/providers/weather/variables.rs b/packages/desktop/src/providers/weather/variables.rs index 94f5df50..8f558e38 100644 --- a/packages/desktop/src/providers/weather/variables.rs +++ b/packages/desktop/src/providers/weather/variables.rs @@ -2,7 +2,7 @@ use serde::Serialize; #[derive(Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct WeatherVariables { +pub struct WeatherOutput { pub is_daytime: bool, pub status: WeatherStatus, pub celsius_temp: f32, diff --git a/packages/desktop/src/sys_tray.rs b/packages/desktop/src/sys_tray.rs index 36612ab7..84d619d1 100644 --- a/packages/desktop/src/sys_tray.rs +++ b/packages/desktop/src/sys_tray.rs @@ -1,43 +1,351 @@ -use anyhow::Context; +use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::Arc}; + +use anyhow::{bail, Context}; use tauri::{ - menu::MenuBuilder, + image::Image, + menu::{CheckMenuItem, Menu, MenuBuilder, Submenu, SubmenuBuilder}, tray::{TrayIcon, TrayIconBuilder}, + AppHandle, Wry, }; +use tokio::task; use tracing::{error, info}; -use crate::user_config::open_config_dir; - -pub fn setup_sys_tray(app: &mut tauri::App) -> anyhow::Result { - let icon_image = app - .default_window_icon() - .context("No icon defined in Tauri config.")?; - - let tray_menu = MenuBuilder::new(app) - .text("show_config_folder", "Show config folder") - .separator() - .text("exit", "Exit") - .build()?; - - let tray_icon = TrayIconBuilder::with_id("tray") - .icon(icon_image.clone()) - .menu(&tray_menu) - .tooltip(format!("Zebar v{}", env!("VERSION_NUMBER"))) - .on_menu_event(move |app, event| match event.id().as_ref() { - "show_config_folder" => { +use crate::{ + common::PathExt, + config::Config, + window_factory::{WindowFactory, WindowState}, +}; + +#[derive(Debug, Clone)] +enum MenuEvent { + ShowConfigFolder, + ReloadConfigs, + Exit, + ToggleWindowConfig { enable: bool, path: PathBuf }, + ToggleStartupWindowConfig { enable: bool, path: PathBuf }, +} + +impl ToString for MenuEvent { + fn to_string(&self) -> String { + match self { + MenuEvent::ShowConfigFolder => "show_config_folder".to_string(), + MenuEvent::ReloadConfigs => "reload_configs".to_string(), + MenuEvent::Exit => "exit".to_string(), + MenuEvent::ToggleWindowConfig { enable, path } => { + format!( + "toggle_window_config_{}_{}", + enable, + path.to_unicode_string() + ) + } + MenuEvent::ToggleStartupWindowConfig { enable, path } => { + format!( + "toggle_startup_window_config_{}_{}", + enable, + path.to_unicode_string() + ) + } + } + } +} + +impl FromStr for MenuEvent { + type Err = anyhow::Error; + + fn from_str(event: &str) -> Result { + let parts: Vec<&str> = event.split('_').collect(); + + match parts.as_slice() { + ["show", "config", "folder"] => Ok(Self::ShowConfigFolder), + ["reload", "configs"] => Ok(Self::ReloadConfigs), + ["exit"] => Ok(Self::Exit), + ["toggle", "window", "config", enable @ ("true" | "false"), path @ ..] => { + Ok(Self::ToggleWindowConfig { + enable: *enable == "true", + path: PathBuf::from(path.join("_")), + }) + } + ["toggle", "startup", "window", "config", enable @ ("true" | "false"), path @ ..] => { + Ok(Self::ToggleStartupWindowConfig { + enable: *enable == "true", + path: PathBuf::from(path.join("_")), + }) + } + _ => bail!("Invalid menu event: {}", event), + } + } +} + +/// System tray icon for Zebar. +pub struct SysTray { + app_handle: AppHandle, + config: Arc, + window_factory: Arc, + tray_icon: Option, +} + +impl SysTray { + /// Creates a new system tray icon for Zebar. + pub async fn new( + app_handle: &AppHandle, + config: Arc, + window_factory: Arc, + ) -> anyhow::Result> { + let mut sys_tray = Self { + app_handle: app_handle.clone(), + config, + window_factory, + tray_icon: None, + }; + + sys_tray.tray_icon = Some(sys_tray.create_tray_icon().await?); + + Ok(Arc::new(sys_tray)) + } + + async fn create_tray_icon(&self) -> anyhow::Result { + let config = self.config.clone(); + let window_factory = self.window_factory.clone(); + let tooltip = format!("Zebar v{}", env!("VERSION_NUMBER")); + + let tray_icon = TrayIconBuilder::with_id("tray") + .icon(self.icon_image()?) + .menu(&self.create_tray_menu().await?) + .tooltip(tooltip) + .on_menu_event(move |app, event| { + let app_handle = app.clone(); + let config = config.clone(); + let window_factory = window_factory.clone(); + + task::spawn(async move { + let event = MenuEvent::from_str(event.id.as_ref()); + + if let Ok(event) = event { + info!("Received tray menu event: {}", event.to_string()); + + let res = Self::handle_menu_event( + event, + app_handle, + config, + window_factory, + ) + .await; + + if let Err(err) = res { + error!("{:?}", err); + } + } + }); + }) + .build(&self.app_handle)?; + + Ok(tray_icon) + } + + pub async fn refresh(&self) -> anyhow::Result<()> { + info!("Updating system tray menu."); + + if let Some(tray_icon) = self.tray_icon.as_ref() { + let tray_menu = self.create_tray_menu().await?; + tray_icon.set_menu(Some(tray_menu))?; + } + + Ok(()) + } + + /// Returns the image to use for the system tray icon. + fn icon_image(&self) -> anyhow::Result { + self + .app_handle + .default_window_icon() + .cloned() + .context("No icon defined in Tauri config.") + } + + /// Creates and returns the main system tray menu. + async fn create_tray_menu(&self) -> anyhow::Result> { + let window_states = self.window_factory.states_by_config_path().await; + + let startup_config_paths = self + .config + .startup_window_configs() + .await? + .into_iter() + .map(|entry| entry.config_path) + .collect(); + + let configs_menu = self + .create_configs_menu(&window_states, &startup_config_paths) + .await?; + + let mut tray_menu = MenuBuilder::new(&self.app_handle) + .item(&configs_menu) + .text(MenuEvent::ShowConfigFolder, "Show config folder") + .text(MenuEvent::ReloadConfigs, "Reload configs") + .separator(); + + // Add submenus for currently active windows. + if !window_states.is_empty() { + for (config_path, states) in window_states { + let label = format!( + "({}) {}", + states.len(), + Self::format_config_path(&self.config, &config_path) + ); + + tray_menu = tray_menu.item(&self.create_config_menu( + &config_path, + &label, + !states.is_empty(), + startup_config_paths.contains(&config_path), + )?); + } + + tray_menu = tray_menu.separator(); + } + + tray_menu = tray_menu.text(MenuEvent::Exit, "Exit"); + + Ok(tray_menu.build()?) + } + + /// Callback for system tray menu events. + async fn handle_menu_event( + event: MenuEvent, + app_handle: AppHandle, + config: Arc, + window_factory: Arc, + ) -> anyhow::Result<()> { + match event { + MenuEvent::ShowConfigFolder => { info!("Opening config folder from system tray."); - if let Err(err) = open_config_dir(app) { - error!("Failed to open config folder: {}", err); - } + + config + .open_config_dir() + .context("Failed to open config folder.") + } + MenuEvent::ReloadConfigs => { + info!("Opening config folder from system tray."); + + config.reload().await } - "exit" => { + MenuEvent::Exit => { info!("Exiting through system tray."); - app.exit(0) + + app_handle.exit(0); + + Ok(()) } - other => { - error!("Unknown menu event: {}", other); + MenuEvent::ToggleWindowConfig { enable, path } => { + info!( + "Window config '{}' to be enabled: {}", + path.display(), + enable + ); + + match enable { + true => { + let window_config = config + .window_config_by_path(&path) + .await? + .context("Window config not found.")?; + + window_factory.open(window_config).await + } + false => window_factory.close_by_path(&path).await, + } + } + MenuEvent::ToggleStartupWindowConfig { enable, path } => { + info!( + "Window config '{}' to be launched on startup: {}", + path.display(), + enable + ); + + match enable { + true => config.add_startup_config(&path).await, + false => config.remove_startup_config(&path).await, + } } - }) - .build(app)?; + } + } + + /// Creates and returns a submenu for the window configs. + async fn create_configs_menu( + &self, + window_states: &HashMap>, + startup_config_paths: &Vec, + ) -> anyhow::Result> { + let mut configs_menu = + SubmenuBuilder::new(&self.app_handle, "Window configs"); + + // Add each window config to the menu. + for window_config in &self.config.window_configs().await { + let label = + Self::format_config_path(&self.config, &window_config.config_path); + + let menu_item = self.create_config_menu( + &window_config.config_path, + &label, + window_states.contains_key(&window_config.config_path), + startup_config_paths.contains(&window_config.config_path), + )?; + + configs_menu = configs_menu.item(&menu_item); + } + + Ok(configs_menu.build()?) + } + + /// Creates and returns a submenu for the given window config. + fn create_config_menu( + &self, + config_path: &PathBuf, + label: &str, + is_enabled: bool, + is_launched_on_startup: bool, + ) -> anyhow::Result> { + let enabled_item = CheckMenuItem::with_id( + &self.app_handle, + MenuEvent::ToggleWindowConfig { + enable: !is_enabled, + path: config_path.clone(), + }, + "Enabled", + true, + is_enabled, + None::<&str>, + )?; + + let startup_item = CheckMenuItem::with_id( + &self.app_handle, + MenuEvent::ToggleStartupWindowConfig { + enable: !is_launched_on_startup, + path: config_path.clone(), + }, + "Launch on startup", + true, + is_launched_on_startup, + None::<&str>, + )?; + + let config_menu = SubmenuBuilder::new(&self.app_handle, label) + .item(&enabled_item) + .item(&startup_item); + + Ok(config_menu.build()?) + } + + /// Formats the config path for display in the system tray. + fn format_config_path( + config: &Arc, + config_path: &PathBuf, + ) -> String { + let path = config + .strip_config_dir(config_path) + .unwrap_or(config_path.clone()) + .to_unicode_string(); - Ok(tray_icon) + path.strip_suffix(".zebar.json").unwrap_or(&path).into() + } } diff --git a/packages/desktop/src/user_config.rs b/packages/desktop/src/user_config.rs deleted file mode 100644 index 652ac23b..00000000 --- a/packages/desktop/src/user_config.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::{fs, path::PathBuf}; - -use anyhow::Context; -use tauri::{path::BaseDirectory, AppHandle, Manager}; - -/// Reads the config file at `~/.glzr/zebar/config.yaml`. -pub fn read_file( - config_path_override: Option<&str>, - app_handle: AppHandle, -) -> anyhow::Result { - let default_config_path = app_handle - .path() - .resolve(".glzr/zebar/config.yaml", BaseDirectory::Home) - .context("Unable to get home directory.")?; - - let config_path = match config_path_override { - Some(val) => PathBuf::from(val), - None => default_config_path, - }; - - // Create new config file from sample if it doesn't exist. - if !config_path.exists() { - create_from_sample(&config_path, app_handle)?; - } - - fs::read_to_string(&config_path).context("Unable to read config file.") -} - -/// Initialize config at the given path from the sample config resource. -fn create_from_sample( - config_path: &PathBuf, - app_handle: AppHandle, -) -> anyhow::Result<()> { - let sample_path = app_handle - .path() - .resolve("resources/sample-config.yaml", BaseDirectory::Resource) - .context("Unable to resolve sample config resource.")?; - - let sample_script = app_handle - .path() - .resolve("resources/script.js", BaseDirectory::Resource) - .context("Unable to resolve sample script resource.")?; - - let dest_dir = - config_path.parent().context("Invalid config directory.")?; - - // Create the destination directory. - std::fs::create_dir_all(&dest_dir).with_context(|| { - format!("Unable to create directory {}.", &config_path.display()) - })?; - - // Copy over sample config. - let config_path = dest_dir.join("config.yaml"); - fs::copy(&sample_path, &config_path).with_context(|| { - format!("Unable to write to {}.", config_path.display()) - })?; - - // Copy over sample script. - let script_path = dest_dir.join("script.js"); - fs::copy(&sample_script, &script_path).with_context(|| { - format!("Unable to write to {}.", script_path.display()) - })?; - - Ok(()) -} - -pub fn open_config_dir(app_handle: &AppHandle) -> anyhow::Result<()> { - let dir_path = app_handle - .path() - .resolve(".glzr/zebar", BaseDirectory::Home) - .context("Unable to get home directory.")? - .canonicalize()?; - - #[cfg(target_os = "windows")] - { - std::process::Command::new("explorer") - .arg(dir_path) - .spawn()?; - } - - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg(dir_path) - .arg("-R") - .spawn()?; - } - - #[cfg(target_os = "linux")] - { - std::process::Command::new("xdg-open") - .arg(dir_path) - .spawn()?; - } - - Ok(()) -} diff --git a/packages/desktop/src/util/mod.rs b/packages/desktop/src/util/mod.rs deleted file mode 100644 index 7ccdb6b0..00000000 --- a/packages/desktop/src/util/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod window_ext; diff --git a/packages/desktop/src/window_factory.rs b/packages/desktop/src/window_factory.rs index a32e5524..0d55922f 100644 --- a/packages/desktop/src/window_factory.rs +++ b/packages/desktop/src/window_factory.rs @@ -1,126 +1,364 @@ use std::{ collections::HashMap, + path::PathBuf, sync::{ atomic::{AtomicU32, Ordering}, Arc, }, }; +use anyhow::{bail, Context}; use serde::Serialize; -use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder}; -use tokio::{sync::Mutex, task}; +use tauri::{ + AppHandle, LogicalPosition, LogicalSize, Manager, PhysicalPosition, + PhysicalSize, WebviewUrl, WebviewWindowBuilder, WindowEvent, +}; +use tokio::{ + sync::{broadcast, Mutex}, + task, +}; use tracing::{error, info}; -use crate::{cli::OpenWindowArgs, util::window_ext::WindowExt}; +use crate::{ + common::{PathExt, WindowExt}, + config::{Config, WindowAnchor, WindowConfig, WindowConfigEntry}, + monitor_state::MonitorState, +}; /// Manages the creation of Zebar windows. pub struct WindowFactory { + /// Handle to the Tauri application. app_handle: AppHandle, + + _close_rx: broadcast::Receiver, + + pub close_tx: broadcast::Sender, + + /// Reference to `Config`. + config: Arc, + + _open_rx: broadcast::Receiver, + + pub open_tx: broadcast::Sender, + + /// Reference to `MonitorState`. + /// + /// Used for window positioning. + monitor_state: Arc, + + /// Running total of windows created. + /// + /// Used to generate unique window labels. window_count: Arc, + + /// Map of window labels to window states. window_states: Arc>>, } #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct WindowState { + /// Unique identifier for the window. + /// + /// Used as the window label. pub window_id: String, - pub window_label: String, - pub args: HashMap, - pub env: HashMap, + + /// User-defined config for the window. + pub config: WindowConfig, + + /// Absolute path to the window's config file. + pub config_path: PathBuf, + + /// Absolute path to the window's HTML file. + pub html_path: PathBuf, } impl WindowFactory { - pub fn new(app_handle: &AppHandle) -> Self { + /// Creates a new `WindowFactory` instance. + pub fn new( + app_handle: &AppHandle, + config: Arc, + monitor_state: Arc, + ) -> Self { + let (open_tx, _open_rx) = broadcast::channel(16); + let (close_tx, _close_rx) = broadcast::channel(16); + Self { app_handle: app_handle.clone(), + _close_rx, + close_tx, + config, + _open_rx, + open_tx, + monitor_state, window_count: Arc::new(AtomicU32::new(0)), window_states: Arc::new(Mutex::new(HashMap::new())), } } - pub fn try_open(&self, open_args: OpenWindowArgs) { - let app_handle = self.app_handle.clone(); - let window_states = self.window_states.clone(); - let app_handle = app_handle.clone(); - let window_count = self.window_count.clone(); + /// Opens windows from a given config entry. + pub async fn open( + &self, + config_entry: WindowConfigEntry, + ) -> anyhow::Result<()> { + let WindowConfigEntry { + config, + config_path, + html_path, + } = &config_entry; - task::spawn(async move { - // Increment number of windows. - let new_count = window_count.fetch_add(1, Ordering::Relaxed) + 1; + for (size, position) in self.window_placements(config).await { + // Use running window count as a unique ID for the window. + let new_count = + self.window_count.fetch_add(1, Ordering::Relaxed) + 1; + let window_id = new_count.to_string(); + + if !html_path.exists() { + bail!( + "HTML file not found at {} for config {}.", + html_path.display(), + config_path.display() + ); + } + + info!( + "Creating window #{} from {}", + new_count, + config_path.display() + ); + + // TODO: Url-encode the HTML path to get this working on MacOS/Linux. + let webview_url = WebviewUrl::App( + format!( + "http://asset.localhost/{}", + html_path.to_unicode_string() + ) + .into(), + ); + + // Note that window label needs to be globally unique. + let window = WebviewWindowBuilder::new( + &self.app_handle, + window_id.clone(), + webview_url, + ) + .title("Zebar") + .inner_size(size.width, size.height) + .position(position.x, position.y) + .focused(config.launch_options.focused) + .skip_taskbar(!config.launch_options.shown_in_taskbar) + .visible_on_all_workspaces(true) + .transparent(config.launch_options.transparent) + .shadow(false) + .decorations(false) + .resizable(config.launch_options.resizable) + .build()?; + + let state = WindowState { + window_id: window_id.clone(), + config: config.clone(), + config_path: config_path.clone(), + html_path: html_path.clone(), + }; + + _ = window.eval(&format!( + "window.__ZEBAR_INITIAL_STATE={}", + serde_json::to_string(&state)? + )); + + // On Windows, Tauri's `skip_taskbar` option isn't 100% reliable, so + // we also set the window as a tool window. + #[cfg(target_os = "windows")] + let _ = window + .as_ref() + .window() + .set_tool_window(!config.launch_options.shown_in_taskbar); + + // On Windows, there's an issue where the window size is constrained + // when initially created. To work around this, apply the size and + // position settings again after launch. + #[cfg(target_os = "windows")] + { + let _ = window.set_size(size); + let _ = window.set_position(position); + } + + let mut window_states = self.window_states.lock().await; + window_states.insert(state.window_id.clone(), state.clone()); + + self.register_window_events(&window, window_id); + self.open_tx.send(state)?; + } + + Ok(()) + } + + /// Registers window events for a given window. + fn register_window_events( + &self, + window: &tauri::WebviewWindow, + window_id: String, + ) { + let window_states = self.window_states.clone(); + let close_tx = self.close_tx.clone(); - let open_res = Self::open(&app_handle, open_args, new_count); + window.on_window_event(move |event| { + if let WindowEvent::Destroyed = event { + let window_states = window_states.clone(); + let close_tx = close_tx.clone(); + let window_id = window_id.clone(); - match open_res { - Ok(state) => { + task::spawn(async move { let mut window_states = window_states.lock().await; - window_states.insert(state.window_label.clone(), state); - } - Err(err) => { - error!("Failed to open window: {:?}", err); - } + + // Remove the window state and broadcast the close event. + if let Some(state) = window_states.remove(&window_id) { + if let Err(err) = close_tx.send(state) { + error!("Failed to send window close event: {:?}", err); + } + } + }); } }); } - fn open( - app_handle: &AppHandle, - open_args: OpenWindowArgs, - window_count: u32, - ) -> anyhow::Result { - let args = open_args.args.unwrap_or(vec![]).into_iter().collect(); - - info!( - "Creating window #{} '{}' with args: {:#?}", - window_count, open_args.window_id, args - ); - - // Window label needs to be globally unique. Hence add a prefix with - // the window count to handle cases where multiple of the same window - // are opened. - let window_label = - format!("{}-{}", window_count, &open_args.window_id); - - let window = WebviewWindowBuilder::new( - app_handle, - &window_label, - WebviewUrl::default(), - ) - .title(format!("Zebar - {}", open_args.window_id)) - .inner_size(500., 500.) - .focused(false) - .skip_taskbar(true) - .visible_on_all_workspaces(true) - .transparent(true) - .shadow(false) - .decorations(false) - .resizable(false) - .build()?; - - let state = WindowState { - window_id: open_args.window_id.clone(), - window_label: window_label.clone(), - args, - env: std::env::vars().collect(), - }; - - _ = window.eval(&format!( - "window.__ZEBAR_OPEN_ARGS={}", - serde_json::to_string(&state)? - )); - - // Tauri's `skip_taskbar` option isn't 100% reliable, so we - // also set the window as a tool window. - #[cfg(target_os = "windows")] - let _ = window.as_ref().window().set_tool_window(true); - - Ok(state) + /// Returns coordinates for window placement based on the given config. + async fn window_placements( + &self, + config: &WindowConfig, + ) -> Vec<(LogicalSize, LogicalPosition)> { + let mut placements = vec![]; + + for placement in config.launch_options.placements.iter() { + let monitors = self + .monitor_state + .monitors_by_selection(&placement.monitor_selection) + .await; + + for monitor in monitors { + let (anchor_x, anchor_y) = match placement.anchor { + WindowAnchor::TopLeft => (monitor.x, monitor.y), + WindowAnchor::TopCenter => { + (monitor.x + (monitor.width as i32 / 2), monitor.y) + } + WindowAnchor::TopRight => { + (monitor.x + monitor.width as i32, monitor.y) + } + WindowAnchor::CenterLeft => { + (monitor.x, monitor.y + (monitor.height as i32 / 2)) + } + WindowAnchor::Center => ( + monitor.x + (monitor.width as i32 / 2), + monitor.y + (monitor.height as i32 / 2), + ), + WindowAnchor::CenterRight => ( + monitor.x + monitor.width as i32, + monitor.y + (monitor.height as i32 / 2), + ), + WindowAnchor::BottomLeft => { + (monitor.x, monitor.y + monitor.height as i32) + } + WindowAnchor::BottomCenter => ( + monitor.x + (monitor.width as i32 / 2), + monitor.y + monitor.height as i32, + ), + WindowAnchor::BottomRight => ( + monitor.x + monitor.width as i32, + monitor.y + monitor.height as i32, + ), + }; + + let size = LogicalSize::from_physical( + PhysicalSize::new( + placement.width.to_px(monitor.width as i32), + placement.height.to_px(monitor.height as i32), + ), + monitor.scale_factor, + ); + + let position = LogicalPosition::from_physical( + PhysicalPosition::new( + anchor_x + placement.offset_x.to_px(monitor.width as i32), + anchor_y + placement.offset_y.to_px(monitor.height as i32), + ), + monitor.scale_factor, + ); + + placements.push((size, position)); + } + } + + placements } - /// Gets an open window's state by a given window label. - pub async fn state_by_window_label( + /// Closes a single window by a given window ID. + pub fn close_by_id(&self, window_id: &str) -> anyhow::Result<()> { + let window = self + .app_handle + .get_webview_window(window_id) + .context("No window found with the given ID.")?; + + window.close()?; + + Ok(()) + } + + /// Closes all windows with the given config path. + pub async fn close_by_path( &self, - window_label: String, - ) -> Option { - self.window_states.lock().await.get(&window_label).cloned() + config_path: &PathBuf, + ) -> anyhow::Result<()> { + let window_states = self.states_by_config_path().await; + + let found_window_states = window_states + .get(config_path) + .context("No windows found with the given config path.")?; + + for window_state in found_window_states { + self.close_by_id(&window_state.window_id)?; + } + + Ok(()) + } + + /// Relaunches all currently open windows. + pub async fn relaunch_all(&self) -> anyhow::Result<()> { + let window_states = self.states_by_config_path().await; + + for (config_path, _) in window_states { + let _ = self.close_by_path(&config_path).await; + + let window_config = self + .config + .window_config_by_path(&config_path) + .await? + .context("Window config not found.")?; + + self.open(window_config).await?; + } + + Ok(()) + } + + /// Returns window state by a given window ID. + pub async fn state_by_id(&self, window_id: &str) -> Option { + self.window_states.lock().await.get(window_id).cloned() + } + + /// Returns window states grouped by their config paths. + pub async fn states_by_config_path( + &self, + ) -> HashMap> { + self.window_states.lock().await.values().fold( + HashMap::new(), + |mut acc, state| { + acc + .entry(state.config_path.clone()) + .or_insert_with(Vec::new) + .push(state.clone()); + + acc + }, + ) } } diff --git a/packages/desktop/tauri.conf.json b/packages/desktop/tauri.conf.json index 8f26164a..b67d233e 100644 --- a/packages/desktop/tauri.conf.json +++ b/packages/desktop/tauri.conf.json @@ -1,17 +1,12 @@ { "$schema": "node_modules/@tauri-apps/cli/schema.json", "build": { - "devUrl": "http://localhost:4200", - "frontendDist": "../client/dist" + "devUrl": null, + "frontendDist": null }, "productName": "Zebar", "version": "0.0.0", "identifier": "com.glzr.zebar", - "plugins": { - "shell": { - "open": true - } - }, "bundle": { "active": true, "icon": [ @@ -24,7 +19,7 @@ "shortDescription": "Zebar", "category": "Utility", "publisher": "Glzr Software Pte. Ltd.", - "resources": ["resources/*"], + "resources": ["resources/*", "../../examples/*"], "targets": ["deb", "appimage", "msi", "dmg"], "windows": { "signCommand": "powershell -ExecutionPolicy Bypass -File ./resources/scripts/sign.ps1 -FilePath %1", @@ -45,8 +40,7 @@ "img-src": "'self' asset: http://asset.localhost blob: data: *" }, "assetProtocol": { - "enable": true, - "scope": ["$HOME/.glzr/zebar/**"] + "enable": true } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8280174..6922fc5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,36 +15,24 @@ importers: specifier: 3.2.5 version: 3.2.5 - packages/client: + examples/boilerplates/solid-ts: dependencies: - morphdom: - specifier: 2.7.4 - version: 2.7.4 solid-js: - specifier: 1.8.14 - version: 1.8.14 + specifier: 1.8.11 + version: 1.8.11 zebar: - specifier: workspace:* - version: link:../client-api + specifier: ^2.0.0 + version: link:../../../packages/client-api devDependencies: - '@types/node': - specifier: 20.11.17 - version: 20.11.17 typescript: specifier: 5.3.3 version: 5.3.3 vite: - specifier: 5.1.1 - version: 5.1.1(@types/node@20.11.17)(sass@1.70.0) - vite-plugin-checker: - specifier: 0.6.4 - version: 0.6.4(typescript@5.3.3)(vite@5.1.1(@types/node@20.11.17)(sass@1.70.0)) + specifier: 5.0.11 + version: 5.0.11(@types/node@20.11.17)(sass@1.70.0) vite-plugin-solid: - specifier: 2.9.1 - version: 2.9.1(solid-js@1.8.14)(vite@5.1.1(@types/node@20.11.17)(sass@1.70.0)) - wait-on: - specifier: 7.2.0 - version: 7.2.0 + specifier: 2.8.2 + version: 2.8.2(solid-js@1.8.11)(vite@5.0.11(@types/node@20.11.17)(sass@1.70.0)) packages/client-api: dependencies: @@ -58,17 +46,11 @@ importers: specifier: 2.0.0-beta.8 version: 2.0.0-beta.8 glazewm: - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.3 + version: 1.4.3 luxon: specifier: 3.4.4 version: 3.4.4 - solid-js: - specifier: 1.8.14 - version: 1.8.14 - yaml: - specifier: 2.3.4 - version: 2.3.4 zod: specifier: 3.22.4 version: 3.22.4 @@ -82,9 +64,6 @@ importers: tsup: specifier: 8.0.2 version: 8.0.2(postcss@8.4.35)(typescript@5.3.3) - tsup-preset-solid: - specifier: 2.2.0 - version: 2.2.0(esbuild@0.20.0)(solid-js@1.8.14)(tsup@8.0.2(postcss@8.4.35)(typescript@5.3.3)) typescript: specifier: 5.3.3 version: 5.3.3 @@ -94,9 +73,6 @@ importers: '@tauri-apps/cli': specifier: 2.0.0-beta.22 version: 2.0.0-beta.22 - '@zebar/client': - specifier: workspace:* - version: link:../client typescript: specifier: 5.3.3 version: 5.3.3 @@ -551,12 +527,6 @@ packages: typescript: optional: true - '@hapi/hoek@9.3.0': - resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} - - '@hapi/topo@5.1.0': - resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -664,15 +634,6 @@ packages: cpu: [x64] os: [win32] - '@sideway/address@4.1.4': - resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} - - '@sideway/formula@3.0.1': - resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} - - '@sideway/pinpoint@2.0.0': - resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - '@tauri-apps/api@2.0.0-beta.15': resolution: {integrity: sha512-H9w6iISmR+NvH4XuyCZB4zDN10tf9RFt6i/9JHEjaRhAowdAaJ+oiXq/3kedizNClHMtbTQ5j0oqDVPkZDAI8g==} engines: {node: '>= 18.18', npm: '>= 6.6.0', yarn: '>= 1.19.1'} @@ -769,10 +730,6 @@ packages: '@types/node@20.11.17': resolution: {integrity: sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -804,12 +761,6 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - axios@1.6.7: - resolution: {integrity: sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==} - babel-plugin-jsx-dom-expressions@0.37.13: resolution: {integrity: sha512-oAEMMIgU0h1DmHn4ZDaBBFc08nsVJciLq9pF7g0ZdpeIDKfY4zXjXr8+/oBjKhXG8nyomhnTodPjeG+/ZXcWXQ==} peerDependencies: @@ -835,9 +786,6 @@ packages: resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} engines: {node: '>= 5.10.0'} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} @@ -871,10 +819,6 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -892,21 +836,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -938,10 +871,6 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - detect-indent@7.0.1: resolution: {integrity: sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==} engines: {node: '>=12.20'} @@ -966,12 +895,6 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - esbuild-plugin-solid@0.5.0: - resolution: {integrity: sha512-ITK6n+0ayGFeDVUZWNMxX+vLsasEN1ILrg4pISsNOQ+mq4ljlJJiuXotInd+HE0MzwTcA9wExT1yzDE2hsqPsg==} - peerDependencies: - esbuild: '>=0.12' - solid-js: '>= 1.0' - esbuild@0.19.11: resolution: {integrity: sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==} engines: {node: '>=12'} @@ -1009,27 +932,10 @@ packages: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} - follow-redirects@1.15.5: - resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - foreground-child@3.1.1: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} - form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - - fs-extra@11.2.0: - resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} - engines: {node: '>=14.14'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1050,8 +956,8 @@ packages: git-hooks-list@3.1.0: resolution: {integrity: sha512-LF8VeHeR7v+wAbXqfgRlTSX/1BJR9Q1vEMR8JAz1cEg6GX07+zyj3sAdDvYjj/xnlIfVuGgj4qBei1K3hKH+PA==} - glazewm@1.4.1: - resolution: {integrity: sha512-rltNAKzTY/g8XEGR+LkyDFIhw9AOq0GW0FqT1RAe54KN738YuPR+yJ72UIDfrXwSpH1Ztb6Ii72qBQPaOjlvNg==} + glazewm@1.4.3: + resolution: {integrity: sha512-V5YFRjI9SNu6TODloDnZuEH4lstyb+d1N01H8OLgVba2uzfCwt0ateJ7U96Dh0WQfHxJPaS4q1XeesHeOVFrIg==} engines: {node: '>=12'} peerDependencies: ws: '*' @@ -1080,17 +986,10 @@ packages: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} @@ -1171,9 +1070,6 @@ packages: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} - joi@17.11.0: - resolution: {integrity: sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==} - joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1191,9 +1087,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - lilconfig@3.0.0: resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} engines: {node: '>=14'} @@ -1208,9 +1101,6 @@ packages: lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - lru-cache@10.1.0: resolution: {integrity: sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==} engines: {node: 14 || >=16.14} @@ -1218,10 +1108,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - luxon@3.4.4: resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} engines: {node: '>=12'} @@ -1241,14 +1127,6 @@ packages: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -1257,23 +1135,14 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.0.4: resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} engines: {node: '>=16 || 14 >=14.17'} - morphdom@2.7.4: - resolution: {integrity: sha512-ATTbWMgGa+FaMU3FhnFYB6WgulCqwf6opOll4CBzmVDTLvPMmUPrEv8CudmLPK0MESa64+6B89fWOxP3+YIlxQ==} - ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -1376,9 +1245,6 @@ packages: engines: {node: '>=14'} hasBin: true - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1410,9 +1276,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.1: - resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} - s.color@0.0.15: resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==} @@ -1428,11 +1291,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.5.4: - resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} - engines: {node: '>=10'} - hasBin: true - seroval-plugins@1.0.4: resolution: {integrity: sha512-DQ2IK6oQVvy8k+c2V5x5YCtUa/GGGsUwUBNN9UqohrZ0rWdUapBFpNMYP1bCyRHoxOJjdKGl+dieacFIpU/i1A==} engines: {node: '>=10'} @@ -1466,8 +1324,8 @@ packages: resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} engines: {node: '>=12'} - solid-js@1.8.14: - resolution: {integrity: sha512-kDfgHBm+ROVLDVuqaXh/jYz0ZVJ29TYfVsKsgDPtNcjoyaPtOvDX2l0tVnthjLdEXr7vDTYeqEYFfMkZakDsOQ==} + solid-js@1.8.11: + resolution: {integrity: sha512-WdwmER+TwBJiN4rVQTVBxocg+9pKlOs41KzPYntrC86xO5sek8TzBYozPEZPL1IRWDouf2lMrvSbIs3CanlPvQ==} solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} @@ -1525,10 +1383,6 @@ packages: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - synckit@0.8.5: resolution: {integrity: sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1540,9 +1394,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - tiny-invariant@1.3.1: - resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} - titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} @@ -1571,11 +1422,6 @@ packages: tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - tsup-preset-solid@2.2.0: - resolution: {integrity: sha512-sPAzeArmYkVAZNRN+m4tkiojdd0GzW/lCwd4+TQDKMENe8wr2uAuro1s0Z59ASmdBbkXoxLgCiNcuQMyiidMZg==} - peerDependencies: - tsup: ^8.0.0 - tsup@8.0.2: resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==} engines: {node: '>=18'} @@ -1595,10 +1441,6 @@ packages: typescript: optional: true - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - typescript@5.3.3: resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} engines: {node: '>=14.17'} @@ -1607,10 +1449,6 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - untildify@4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} @@ -1624,49 +1462,14 @@ packages: validate-html-nesting@1.2.2: resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} - vite-plugin-checker@0.6.4: - resolution: {integrity: sha512-2zKHH5oxr+ye43nReRbC2fny1nyARwhxdm0uNYp/ERy4YvU9iZpNOsueoi/luXw5gnpqRSvjcEPxXbS153O2wA==} - engines: {node: '>=14.16'} - peerDependencies: - eslint: '>=7' - meow: ^9.0.0 - optionator: ^0.9.1 - stylelint: '>=13' - typescript: '*' - vite: '>=2.0.0' - vls: '*' - vti: '*' - vue-tsc: '>=1.3.9' - peerDependenciesMeta: - eslint: - optional: true - meow: - optional: true - optionator: - optional: true - stylelint: - optional: true - typescript: - optional: true - vls: - optional: true - vti: - optional: true - vue-tsc: - optional: true - - vite-plugin-solid@2.9.1: - resolution: {integrity: sha512-RC4hj+lbvljw57BbMGDApvEOPEh14lwrr/GeXRLNQLcR1qnOdzOwwTSFy13Gj/6FNIZpBEl0bWPU+VYFawrqUw==} + vite-plugin-solid@2.8.2: + resolution: {integrity: sha512-HcvMs6DTxBaO4kE3psnirPQBCUUdYeQkCNKuB2TpEkJsxb6BGP6/7qkbbCSMxn25PyNdjvzVi1WXi0ou8KPgHw==} peerDependencies: - '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* solid-js: ^1.7.2 vite: ^3.0.0 || ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - '@testing-library/jest-dom': - optional: true - vite@5.1.1: - resolution: {integrity: sha512-wclpAgY3F1tR7t9LL5CcHC41YPkQIpKUGeIuT8MdNwNZr6OqOTLs7JX5vIHAtzqLWXts0T+GDrh9pN2arneKqg==} + vite@5.0.11: + resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -1701,35 +1504,6 @@ packages: vite: optional: true - vscode-jsonrpc@6.0.0: - resolution: {integrity: sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==} - engines: {node: '>=8.0.0 || >=10.0.0'} - - vscode-languageclient@7.0.0: - resolution: {integrity: sha512-P9AXdAPlsCgslpP9pRxYPqkNYV7Xq8300/aZDpO35j1fJm/ncize8iGswzYlcvFw5DQUx4eVk+KvfXdL0rehNg==} - engines: {vscode: ^1.52.0} - - vscode-languageserver-protocol@3.16.0: - resolution: {integrity: sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A==} - - vscode-languageserver-textdocument@1.0.11: - resolution: {integrity: sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==} - - vscode-languageserver-types@3.16.0: - resolution: {integrity: sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==} - - vscode-languageserver@7.0.0: - resolution: {integrity: sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==} - hasBin: true - - vscode-uri@3.0.8: - resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - - wait-on@7.2.0: - resolution: {integrity: sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==} - engines: {node: '>=12.0.0'} - hasBin: true - webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -1752,9 +1526,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@2.3.4: resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} engines: {node: '>= 14'} @@ -2118,12 +1889,6 @@ snapshots: prettier: 3.2.5 typescript: 5.3.3 - '@hapi/hoek@9.3.0': {} - - '@hapi/topo@5.1.0': - dependencies: - '@hapi/hoek': 9.3.0 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2213,14 +1978,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.10.0': optional: true - '@sideway/address@4.1.4': - dependencies: - '@hapi/hoek': 9.3.0 - - '@sideway/formula@3.0.1': {} - - '@sideway/pinpoint@2.0.0': {} - '@tauri-apps/api@2.0.0-beta.15': {} '@tauri-apps/cli-darwin-arm64@2.0.0-beta.22': @@ -2302,10 +2059,7 @@ snapshots: '@types/node@20.11.17': dependencies: undici-types: 5.26.5 - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 + optional: true ansi-regex@5.0.1: {} @@ -2330,16 +2084,6 @@ snapshots: array-union@2.1.0: {} - asynckit@0.4.0: {} - - axios@1.6.7: - dependencies: - follow-redirects: 1.15.5 - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - babel-plugin-jsx-dom-expressions@0.37.13(@babel/core@7.23.7): dependencies: '@babel/core': 7.23.7 @@ -2364,11 +2108,6 @@ snapshots: dependencies: big-integer: 1.6.52 - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.1: dependencies: balanced-match: 1.0.2 @@ -2403,11 +2142,6 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chokidar@3.5.3: dependencies: anymatch: 3.1.3 @@ -2432,16 +2166,8 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@4.1.1: {} - commander@8.3.0: {} - - concat-map@0.0.1: {} - convert-source-map@2.0.0: {} cross-spawn@7.0.3: @@ -2470,8 +2196,6 @@ snapshots: define-lazy-prop@3.0.0: {} - delayed-stream@1.0.0: {} - detect-indent@7.0.1: {} detect-newline@4.0.1: {} @@ -2488,16 +2212,6 @@ snapshots: emoji-regex@9.2.2: {} - esbuild-plugin-solid@0.5.0(esbuild@0.20.0)(solid-js@1.8.14): - dependencies: - '@babel/core': 7.23.7 - '@babel/preset-typescript': 7.23.3(@babel/core@7.23.7) - babel-preset-solid: 1.8.9(@babel/core@7.23.7) - esbuild: 0.20.0 - solid-js: 1.8.14 - transitivePeerDependencies: - - supports-color - esbuild@0.19.11: optionalDependencies: '@esbuild/aix-ppc64': 0.19.11 @@ -2594,25 +2308,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 - follow-redirects@1.15.5: {} - foreground-child@3.1.1: dependencies: cross-spawn: 7.0.3 signal-exit: 4.1.0 - form-data@4.0.0: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - - fs-extra@11.2.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - fsevents@2.3.3: optional: true @@ -2624,7 +2324,7 @@ snapshots: git-hooks-list@3.1.0: {} - glazewm@1.4.1: + glazewm@1.4.3: dependencies: tslib: 2.6.1 @@ -2659,12 +2359,8 @@ snapshots: merge2: 1.4.1 slash: 4.0.0 - graceful-fs@4.2.11: {} - has-flag@3.0.0: {} - has-flag@4.0.0: {} - html-entities@2.3.3: {} human-signals@2.1.0: {} @@ -2718,14 +2414,6 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - joi@17.11.0: - dependencies: - '@hapi/hoek': 9.3.0 - '@hapi/topo': 5.1.0 - '@sideway/address': 4.1.4 - '@sideway/formula': 3.0.1 - '@sideway/pinpoint': 2.0.0 - joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -2734,12 +2422,6 @@ snapshots: json5@2.2.3: {} - jsonfile@6.1.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - lilconfig@3.0.0: {} lines-and-columns@1.2.4: {} @@ -2748,18 +2430,12 @@ snapshots: lodash.sortby@4.7.0: {} - lodash@4.17.21: {} - lru-cache@10.1.0: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - luxon@3.4.4: {} merge-anything@5.1.7: @@ -2775,30 +2451,16 @@ snapshots: braces: 3.0.2 picomatch: 2.3.1 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.11 - minimatch@9.0.3: dependencies: brace-expansion: 2.0.1 - minimist@1.2.8: {} - minipass@7.0.4: {} - morphdom@2.7.4: {} - ms@2.1.2: {} mz@2.7.0: @@ -2883,8 +2545,6 @@ snapshots: prettier@3.2.5: {} - proxy-from-env@1.1.0: {} - punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -2924,10 +2584,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rxjs@7.8.1: - dependencies: - tslib: 2.6.2 - s.color@0.0.15: {} sass-formatter@0.7.8: @@ -2943,10 +2599,6 @@ snapshots: semver@6.3.1: {} - semver@7.5.4: - dependencies: - lru-cache: 6.0.0 - seroval-plugins@1.0.4(seroval@1.0.4): dependencies: seroval: 1.0.4 @@ -2967,18 +2619,18 @@ snapshots: slash@4.0.0: {} - solid-js@1.8.14: + solid-js@1.8.11: dependencies: csstype: 3.1.3 seroval: 1.0.4 seroval-plugins: 1.0.4(seroval@1.0.4) - solid-refresh@0.6.3(solid-js@1.8.14): + solid-refresh@0.6.3(solid-js@1.8.11): dependencies: '@babel/generator': 7.23.6 '@babel/helper-module-imports': 7.22.15 '@babel/types': 7.23.6 - solid-js: 1.8.14 + solid-js: 1.8.11 sort-object-keys@1.1.3: {} @@ -3040,10 +2692,6 @@ snapshots: dependencies: has-flag: 3.0.0 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - synckit@0.8.5: dependencies: '@pkgr/utils': 2.4.2 @@ -3057,8 +2705,6 @@ snapshots: dependencies: any-promise: 1.3.0 - tiny-invariant@1.3.1: {} - titleize@3.0.0: {} to-fast-properties@2.0.0: {} @@ -3079,15 +2725,6 @@ snapshots: tslib@2.6.2: {} - tsup-preset-solid@2.2.0(esbuild@0.20.0)(solid-js@1.8.14)(tsup@8.0.2(postcss@8.4.35)(typescript@5.3.3)): - dependencies: - esbuild-plugin-solid: 0.5.0(esbuild@0.20.0)(solid-js@1.8.14) - tsup: 8.0.2(postcss@8.4.35)(typescript@5.3.3) - transitivePeerDependencies: - - esbuild - - solid-js - - supports-color - tsup@8.0.2(postcss@8.4.35)(typescript@5.3.3): dependencies: bundle-require: 4.0.2(esbuild@0.19.11) @@ -3111,13 +2748,10 @@ snapshots: - supports-color - ts-node - type-fest@0.21.3: {} - typescript@5.3.3: {} - undici-types@5.26.5: {} - - universalify@2.0.1: {} + undici-types@5.26.5: + optional: true untildify@4.0.0: {} @@ -3129,41 +2763,21 @@ snapshots: validate-html-nesting@1.2.2: {} - vite-plugin-checker@0.6.4(typescript@5.3.3)(vite@5.1.1(@types/node@20.11.17)(sass@1.70.0)): - dependencies: - '@babel/code-frame': 7.23.5 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - chokidar: 3.5.3 - commander: 8.3.0 - fast-glob: 3.3.2 - fs-extra: 11.2.0 - npm-run-path: 4.0.1 - semver: 7.5.4 - strip-ansi: 6.0.1 - tiny-invariant: 1.3.1 - vite: 5.1.1(@types/node@20.11.17)(sass@1.70.0) - vscode-languageclient: 7.0.0 - vscode-languageserver: 7.0.0 - vscode-languageserver-textdocument: 1.0.11 - vscode-uri: 3.0.8 - optionalDependencies: - typescript: 5.3.3 - - vite-plugin-solid@2.9.1(solid-js@1.8.14)(vite@5.1.1(@types/node@20.11.17)(sass@1.70.0)): + vite-plugin-solid@2.8.2(solid-js@1.8.11)(vite@5.0.11(@types/node@20.11.17)(sass@1.70.0)): dependencies: '@babel/core': 7.23.7 + '@babel/preset-typescript': 7.23.3(@babel/core@7.23.7) '@types/babel__core': 7.20.5 babel-preset-solid: 1.8.9(@babel/core@7.23.7) merge-anything: 5.1.7 - solid-js: 1.8.14 - solid-refresh: 0.6.3(solid-js@1.8.14) - vite: 5.1.1(@types/node@20.11.17)(sass@1.70.0) - vitefu: 0.2.5(vite@5.1.1(@types/node@20.11.17)(sass@1.70.0)) + solid-js: 1.8.11 + solid-refresh: 0.6.3(solid-js@1.8.11) + vite: 5.0.11(@types/node@20.11.17)(sass@1.70.0) + vitefu: 0.2.5(vite@5.0.11(@types/node@20.11.17)(sass@1.70.0)) transitivePeerDependencies: - supports-color - vite@5.1.1(@types/node@20.11.17)(sass@1.70.0): + vite@5.0.11(@types/node@20.11.17)(sass@1.70.0): dependencies: esbuild: 0.19.11 postcss: 8.4.35 @@ -3173,42 +2787,9 @@ snapshots: fsevents: 2.3.3 sass: 1.70.0 - vitefu@0.2.5(vite@5.1.1(@types/node@20.11.17)(sass@1.70.0)): + vitefu@0.2.5(vite@5.0.11(@types/node@20.11.17)(sass@1.70.0)): optionalDependencies: - vite: 5.1.1(@types/node@20.11.17)(sass@1.70.0) - - vscode-jsonrpc@6.0.0: {} - - vscode-languageclient@7.0.0: - dependencies: - minimatch: 3.1.2 - semver: 7.5.4 - vscode-languageserver-protocol: 3.16.0 - - vscode-languageserver-protocol@3.16.0: - dependencies: - vscode-jsonrpc: 6.0.0 - vscode-languageserver-types: 3.16.0 - - vscode-languageserver-textdocument@1.0.11: {} - - vscode-languageserver-types@3.16.0: {} - - vscode-languageserver@7.0.0: - dependencies: - vscode-languageserver-protocol: 3.16.0 - - vscode-uri@3.0.8: {} - - wait-on@7.2.0: - dependencies: - axios: 1.6.7 - joi: 17.11.0 - lodash: 4.17.21 - minimist: 1.2.8 - rxjs: 7.8.1 - transitivePeerDependencies: - - debug + vite: 5.0.11(@types/node@20.11.17)(sass@1.70.0) webidl-conversions@4.0.2: {} @@ -3236,8 +2817,6 @@ snapshots: yallist@3.1.1: {} - yallist@4.0.0: {} - yaml@2.3.4: {} zod@3.22.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 18ec407e..a68fdeb6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - 'packages/*' + - 'examples/starter/*' + - 'examples/boilerplates/*'