diff --git a/.cursor/commands/add-plugin.md b/.cursor/commands/add-plugin.md new file mode 100644 index 0000000000..6e80d26289 --- /dev/null +++ b/.cursor/commands/add-plugin.md @@ -0,0 +1,11 @@ +```bash +npx @tauri-apps/cli plugin new NAME \ +--no-example \ +--directory ./plugins/NAME +``` + +- Base on the instruction, decide `NAME`, and run the above command. +- `plugins/analytics` is well maintained plugin, so follow its style & convention. This including removing `rollup.config.js` & `README.md`, and updating `tsconfig.json` & `package.json`. +- If not specified, keep the single `ping` fn in `ext.rs`, and also in `commands.rs`. +- After all code written, binding should be generated. `pnpm -F @hypr/plugin- codegen`. +- `Cargo.toml`, `apps/desktop/src-tauri/Cargo.toml`, `apps/desktop/package.json`, and `apps/desktop/src-tauri/capabilities/default.json` should be updated as final step. diff --git a/Cargo.lock b/Cargo.lock index d6dc3876b1..b900daa378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object 0.32.2", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -622,6 +631,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ast_node" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4902c7f39335a2390500ee791d6cb1778e742c7b97952497ec81449a5bfa3a7" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.108", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1617,7 +1637,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", ] @@ -1706,6 +1726,15 @@ dependencies = [ "wild", ] +[[package]] +name = "better_scoped_tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +dependencies = [ + "scoped-tls", +] + [[package]] name = "bincode" version = "1.3.3" @@ -2112,6 +2141,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-str" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" +dependencies = [ + "bytes", + "serde", +] + [[package]] name = "bytes-utils" version = "0.1.4" @@ -3900,6 +3939,7 @@ dependencies = [ "tauri-plugin-detect", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-hooks", "tauri-plugin-http", "tauri-plugin-listener", "tauri-plugin-local-stt", @@ -4734,6 +4774,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "from_variant" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308530a56b099da144ebc5d8e179f343ad928fa2b3558d1eb3db9af18d6eff43" +dependencies = [ + "swc_macros_common", + "syn 2.0.108", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -7004,6 +7054,20 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "hstr" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c43c0a9e8fbdb3bb9dc8eee85e1e2ac81605418b4c83b6b7413cbf14d56ca5c" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "rustc-hash 2.1.1", + "serde", + "triomphe", +] + [[package]] name = "htmd" version = "0.3.2" @@ -7704,6 +7768,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -9359,6 +9435,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -10015,6 +10092,15 @@ dependencies = [ "objc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.37.3" @@ -11245,6 +11331,16 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "publicsuffix" version = "2.3.0" @@ -13577,6 +13673,17 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "smol_str" version = "0.1.24" @@ -13751,6 +13858,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -13804,6 +13924,17 @@ dependencies = [ "quote", ] +[[package]] +name = "string_enum" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.108", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -13915,6 +14046,115 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swc_atoms" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40c2b43a19b5d0706aca8669ae5b77b92bd141f7f8ce5e980e0e52430f54b20" +dependencies = [ + "hstr", + "once_cell", + "serde", +] + +[[package]] +name = "swc_common" +version = "16.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e51fecd32bb0989543f0a64f4103cbd728e375838be83d768ce6989f5ea631" +dependencies = [ + "anyhow", + "ast_node", + "better_scoped_tls", + "bytes-str", + "either", + "from_variant", + "new_debug_unreachable", + "num-bigint", + "once_cell", + "rustc-hash 2.1.1", + "serde", + "siphasher 0.3.11", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_visit", + "tracing", + "unicode-width 0.1.14", + "url", +] + +[[package]] +name = "swc_ecma_ast" +version = "17.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8bb0e5aaa6e077f178a28d29bc7da4a8ddaf012b3c21c043cb5f72a0b9779" +dependencies = [ + "bitflags 2.10.0", + "is-macro", + "num-bigint", + "once_cell", + "phf 0.11.3", + "rustc-hash 2.1.1", + "string_enum", + "swc_atoms", + "swc_common", + "swc_visit", + "unicode-id-start", +] + +[[package]] +name = "swc_ecma_parser" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac3281dd9eef03b877fe9cef75a4c8951ce6df0c5f381868f302ee3c58fa6e2" +dependencies = [ + "bitflags 2.10.0", + "either", + "num-bigint", + "phf 0.11.3", + "rustc-hash 2.1.1", + "seq-macro", + "serde", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "swc_macros_common" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "swc_visit" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2" +dependencies = [ + "either", + "new_debug_unreachable", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -14634,6 +14874,26 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-hooks" +version = "0.1.0" +dependencies = [ + "futures-util", + "serde", + "serde_json", + "serde_yaml", + "specta", + "specta-typescript", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "tauri", + "tauri-plugin", + "tauri-specta", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "tauri-plugin-http" version = "2.5.4" @@ -14688,6 +14948,7 @@ dependencies = [ "strum 0.26.3", "tauri", "tauri-plugin", + "tauri-plugin-hooks", "tauri-plugin-local-stt", "tauri-plugin-tray", "tauri-specta", @@ -16417,6 +16678,16 @@ dependencies = [ "petgraph", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -16709,6 +16980,12 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/Cargo.toml b/Cargo.toml index 47d0b19437..ed21df7765 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ tauri-plugin-auth = { path = "plugins/auth" } tauri-plugin-db = { path = "plugins/db" } tauri-plugin-db2 = { path = "plugins/db2" } tauri-plugin-detect = { path = "plugins/detect" } +tauri-plugin-hooks = { path = "plugins/hooks" } tauri-plugin-listener = { path = "plugins/listener" } tauri-plugin-local-llm = { path = "plugins/local-llm" } tauri-plugin-local-stt = { path = "plugins/local-stt" } @@ -161,6 +162,7 @@ serde = "1" serde_bytes = "0.11.15" serde_json = "1" serde_qs = "1.0.0-rc.3" +serde_yaml = "0.9" similar = "2.7.0" statig = "0.4" strum = "0.26" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ff2bd9b1ce..ab05521f74 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,6 +32,7 @@ "@hypr/plugin-analytics": "workspace:*", "@hypr/plugin-db2": "workspace:*", "@hypr/plugin-detect": "workspace:*", + "@hypr/plugin-hooks": "workspace:*", "@hypr/plugin-listener": "workspace:*", "@hypr/plugin-local-stt": "workspace:*", "@hypr/plugin-misc": "workspace:*", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 599d5a172e..07d6e51f60 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri-plugin-deep-link = { workspace = true } tauri-plugin-detect = { workspace = true } tauri-plugin-dialog = { workspace = true } tauri-plugin-fs = { workspace = true } +tauri-plugin-hooks = { workspace = true } tauri-plugin-http = { workspace = true } tauri-plugin-listener = { workspace = true } tauri-plugin-local-stt = { workspace = true } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 3d541d212f..15c96f28a5 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -75,6 +75,7 @@ "process:default", "local-stt:default", "dialog:default", + "hooks:default", "listener:default", "template:default", "notification:default", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5836a169c0..2f0e762201 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -50,6 +50,7 @@ pub async fn main() { .plugin(tauri_plugin_analytics::init()) .plugin(tauri_plugin_db2::init()) .plugin(tauri_plugin_tracing::init()) + .plugin(tauri_plugin_hooks::init()) .plugin(tauri_plugin_listener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_local_stt::init()) diff --git a/apps/desktop/src/store/zustand/listener/general.ts b/apps/desktop/src/store/zustand/listener/general.ts index 33197ce349..b18e415863 100644 --- a/apps/desktop/src/store/zustand/listener/general.ts +++ b/apps/desktop/src/store/zustand/listener/general.ts @@ -2,6 +2,7 @@ import { Effect, Exit } from "effect"; import { create as mutate } from "mutative"; import type { StoreApi } from "zustand"; +import { commands as hooksCommands } from "@hypr/plugin-hooks"; import { type BatchParams, type BatchResponse, @@ -201,6 +202,14 @@ export const createGeneralSlice = < }), ); + hooksCommands + .runEventHooks({ + beforeListeningStarted: { args: { session_id: targetSessionId } }, + }) + .catch((error) => { + console.error("[hooks] BeforeListeningStarted failed:", error); + }); + yield* startSessionEffect(params); set((state) => mutate(state, (draft) => { @@ -237,6 +246,8 @@ export const createGeneralSlice = < }); }, stop: () => { + const sessionId = get().live.sessionId; + const program = Effect.gen(function* () { yield* stopSessionEffect(); }); @@ -251,7 +262,17 @@ export const createGeneralSlice = < }), ); }, - onSuccess: () => {}, + onSuccess: () => { + if (sessionId) { + hooksCommands + .runEventHooks({ + afterListeningStopped: { args: { session_id: sessionId } }, + }) + .catch((error) => { + console.error("[hooks] AfterListeningStopped failed:", error); + }); + } + }, }); }); }, diff --git a/apps/web/content-collections.ts b/apps/web/content-collections.ts index d66fac7b5c..6f518255ea 100644 --- a/apps/web/content-collections.ts +++ b/apps/web/content-collections.ts @@ -127,6 +127,7 @@ const docs = defineCollection({ name: "docs", directory: "content/docs", include: "**/*.mdx", + exclude: "hooks/**", schema: z.object({ title: z.string(), summary: z.string().optional(), @@ -253,6 +254,50 @@ const templates = defineCollection({ }, }); +const hooks = defineCollection({ + name: "hooks", + directory: "content/docs/hooks", + include: "*.mdx", + schema: z.object({ + name: z.string(), + description: z.string(), + args: z + .array( + z.object({ + name: z.string(), + type_name: z.string(), + description: z.string(), + }), + ) + .optional(), + }), + transform: async (document, context) => { + const mdx = await compileMDX(context, document, { + remarkPlugins: [remarkGfm], + rehypePlugins: [ + rehypeSlug, + [ + rehypeAutolinkHeadings, + { + behavior: "wrap", + properties: { + className: ["anchor"], + }, + }, + ], + ], + }); + + const slug = document._meta.path.replace(/\.mdx$/, ""); + + return { + ...document, + mdx, + slug, + }; + }, +}); + export default defineConfig({ - collections: [articles, changelog, docs, legal, templates], + collections: [articles, changelog, docs, legal, templates, hooks], }); diff --git a/apps/web/content/docs/hooks.mdx b/apps/web/content/docs/hooks.mdx new file mode 100644 index 0000000000..9e616234b5 --- /dev/null +++ b/apps/web/content/docs/hooks.mdx @@ -0,0 +1,38 @@ +--- +title: Hooks +description: Learn how to use hooks in Hyprnote +--- + +# Overview + +Hooks let you observe and react to the Hyprnote's lifecycle using custom scripts. + +# Config + +```bash +vi "$HOME/Library/Application Support/hyprnote/hooks.json" +``` + +```json +{ + "version": 1, + "hooks": { + "afterListeningStopped": [{ "command": "./hooks/demo.sh" }] + } +} +``` + +```bash +vi "$HOME/Library/Application Support/hyprnote/hooks/demo.sh" +chmod +x "$HOME/Library/Application Support/hyprnote/hooks/demo.sh" +``` + +```sh +#!/bin/bash +cat > /dev/null +exit 0 +``` + +# Available Hooks + + diff --git a/apps/web/content/docs/hooks/afterListeningStopped.mdx b/apps/web/content/docs/hooks/afterListeningStopped.mdx new file mode 100644 index 0000000000..4cbb6eb4b8 --- /dev/null +++ b/apps/web/content/docs/hooks/afterListeningStopped.mdx @@ -0,0 +1,8 @@ +--- +name: afterListeningStopped +description: '123' +args: +- name: session_id + description: '345' + type_name: string +--- diff --git a/apps/web/content/docs/hooks/beforeListeningStarted.mdx b/apps/web/content/docs/hooks/beforeListeningStarted.mdx new file mode 100644 index 0000000000..da52a0d463 --- /dev/null +++ b/apps/web/content/docs/hooks/beforeListeningStarted.mdx @@ -0,0 +1,8 @@ +--- +name: beforeListeningStarted +description: '123' +args: +- name: session_id + description: '345' + type_name: string +--- diff --git a/apps/web/src/components/hooks-list.tsx b/apps/web/src/components/hooks-list.tsx new file mode 100644 index 0000000000..003c213703 --- /dev/null +++ b/apps/web/src/components/hooks-list.tsx @@ -0,0 +1,54 @@ +import { MDXContent } from "@content-collections/mdx/react"; +import { allHooks } from "content-collections"; + +export function HooksList() { + const hooks = allHooks.sort((a, b) => a.name.localeCompare(b.name)); + + if (hooks.length === 0) { + return null; + } + + return ( +
+ {hooks.map((hook) => ( +
+

+ {hook.name} +

+ {hook.description && ( +

{hook.description}

+ )} + {hook.args && hook.args.length > 0 && ( +
+

Arguments

+
+ {hook.args.map((arg) => ( +
+
+ {arg.name} + + {" "} + : {arg.type_name} + +
+ {arg.description && ( +

+ {arg.description} +

+ )} +
+ ))} +
+
+ )} +
+ +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 865b01bad5..04ab41de2f 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,7 +17,6 @@ import { Route as GithubRouteImport } from './routes/github' import { Route as FoundersRouteImport } from './routes/founders' import { Route as DiscordRouteImport } from './routes/discord' import { Route as ContactRouteImport } from './routes/contact' -import { Route as CalRouteImport } from './routes/cal' import { Route as AuthRouteImport } from './routes/auth' import { Route as ViewRouteRouteImport } from './routes/_view/route' import { Route as ViewIndexRouteImport } from './routes/_view/index' @@ -111,11 +110,6 @@ const ContactRoute = ContactRouteImport.update({ path: '/contact', getParentRoute: () => rootRouteImport, } as any) -const CalRoute = CalRouteImport.update({ - id: '/cal', - path: '/cal', - getParentRoute: () => rootRouteImport, -} as any) const AuthRoute = AuthRouteImport.update({ id: '/auth', path: '/auth', @@ -382,7 +376,6 @@ const ViewAppAccountRoute = ViewAppAccountRouteImport.update({ export interface FileRoutesByFullPath { '/auth': typeof AuthRoute - '/cal': typeof CalRoute '/contact': typeof ContactRoute '/discord': typeof DiscordRoute '/founders': typeof FoundersRoute @@ -444,7 +437,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/auth': typeof AuthRoute - '/cal': typeof CalRoute '/contact': typeof ContactRoute '/discord': typeof DiscordRoute '/founders': typeof FoundersRoute @@ -506,7 +498,6 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_view': typeof ViewRouteRouteWithChildren '/auth': typeof AuthRoute - '/cal': typeof CalRoute '/contact': typeof ContactRoute '/discord': typeof DiscordRoute '/founders': typeof FoundersRoute @@ -570,7 +561,6 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/auth' - | '/cal' | '/contact' | '/discord' | '/founders' @@ -632,7 +622,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/auth' - | '/cal' | '/contact' | '/discord' | '/founders' @@ -693,7 +682,6 @@ export interface FileRouteTypes { | '__root__' | '/_view' | '/auth' - | '/cal' | '/contact' | '/discord' | '/founders' @@ -757,7 +745,6 @@ export interface FileRouteTypes { export interface RootRouteChildren { ViewRouteRoute: typeof ViewRouteRouteWithChildren AuthRoute: typeof AuthRoute - CalRoute: typeof CalRoute ContactRoute: typeof ContactRoute DiscordRoute: typeof DiscordRoute FoundersRoute: typeof FoundersRoute @@ -829,13 +816,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ContactRouteImport parentRoute: typeof rootRouteImport } - '/cal': { - id: '/cal' - path: '/cal' - fullPath: '/cal' - preLoaderRoute: typeof CalRouteImport - parentRoute: typeof rootRouteImport - } '/auth': { id: '/auth' path: '/auth' @@ -1330,7 +1310,6 @@ const ViewRouteRouteWithChildren = ViewRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ViewRouteRoute: ViewRouteRouteWithChildren, AuthRoute: AuthRoute, - CalRoute: CalRoute, ContactRoute: ContactRoute, DiscordRoute: DiscordRoute, FoundersRoute: FoundersRoute, diff --git a/apps/web/src/routes/_view/docs/-components.tsx b/apps/web/src/routes/_view/docs/-components.tsx index 2d17cf0c13..c3a2b11606 100644 --- a/apps/web/src/routes/_view/docs/-components.tsx +++ b/apps/web/src/routes/_view/docs/-components.tsx @@ -15,6 +15,7 @@ import { import { cn } from "@hypr/utils"; import { CtaCard } from "@/components/cta-card"; +import { HooksList } from "@/components/hooks-list"; export function DocLayout({ doc, @@ -114,6 +115,7 @@ function ArticleContent({ doc }: { doc: any }) { Steps, Tip, CtaCard, + HooksList, }} /> diff --git a/plugins/hooks/.gitignore b/plugins/hooks/.gitignore new file mode 100644 index 0000000000..50d8e32e89 --- /dev/null +++ b/plugins/hooks/.gitignore @@ -0,0 +1,17 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +Cargo.lock +node_modules/ + +dist-js +dist diff --git a/plugins/hooks/Cargo.toml b/plugins/hooks/Cargo.toml new file mode 100644 index 0000000000..35720ffe47 --- /dev/null +++ b/plugins/hooks/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tauri-plugin-hooks" +version = "0.1.0" +authors = ["You"] +edition = "2021" +exclude = ["/js", "/node_modules"] +links = "tauri-plugin-hooks" +description = "" + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dev-dependencies] +specta-typescript = { workspace = true } + +swc_common = "16" +swc_ecma_ast = "17" +swc_ecma_parser = "26" + +[dependencies] +tauri = { workspace = true, features = ["test"] } +tauri-specta = { workspace = true, features = ["derive", "typescript"] } + +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +specta = { workspace = true } + +futures-util = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["process"] } diff --git a/plugins/hooks/build.rs b/plugins/hooks/build.rs new file mode 100644 index 0000000000..586922f688 --- /dev/null +++ b/plugins/hooks/build.rs @@ -0,0 +1,5 @@ +const COMMANDS: &[&str] = &["run_event_hooks"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS).build(); +} diff --git a/plugins/hooks/js/bindings.gen.ts b/plugins/hooks/js/bindings.gen.ts new file mode 100644 index 0000000000..64e84a71a8 --- /dev/null +++ b/plugins/hooks/js/bindings.gen.ts @@ -0,0 +1,119 @@ +// @ts-nocheck + + +// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. + +/** user-defined commands **/ + + +export const commands = { +async runEventHooks(event: HookEvent) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|run_event_hooks", { event }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +} +} + +/** user-defined events **/ + + + +/** user-defined constants **/ + + + +/** user-defined types **/ + +/** + * 123 + */ +export type AfterListeningStoppedArgs = { +/** + * 345 + */ +session_id: string } +/** + * 123 + */ +export type BeforeListeningStartedArgs = { +/** + * 345 + */ +session_id: string } +export type HookDefinition = { command: string } +export type HookEvent = { afterListeningStopped: { args: AfterListeningStoppedArgs } } | { beforeListeningStarted: { args: BeforeListeningStartedArgs } } +/** + * 123 + */ +export type HooksConfig = { +/** + * 345 + */ +version: number; +/** + * 678 + */ +hooks?: Partial<{ [key in string]: HookDefinition[] }> } + +/** tauri-specta globals **/ + +import { + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; + +type __EventObj__ = { + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; +}; + +export type Result = + | { status: "ok"; data: T } + | { status: "error"; error: E }; + +function __makeEvents__>( + mappings: Record, +) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); +} diff --git a/plugins/hooks/js/index.ts b/plugins/hooks/js/index.ts new file mode 100644 index 0000000000..a96e122f03 --- /dev/null +++ b/plugins/hooks/js/index.ts @@ -0,0 +1 @@ +export * from "./bindings.gen"; diff --git a/plugins/hooks/package.json b/plugins/hooks/package.json new file mode 100644 index 0000000000..46de86165a --- /dev/null +++ b/plugins/hooks/package.json @@ -0,0 +1,11 @@ +{ + "name": "@hypr/plugin-hooks", + "private": true, + "main": "./js/index.ts", + "scripts": { + "codegen": "cargo test -p tauri-plugin-hooks" + }, + "dependencies": { + "@tauri-apps/api": "^2.9.0" + } +} diff --git a/plugins/hooks/permissions/autogenerated/commands/run_event_hooks.toml b/plugins/hooks/permissions/autogenerated/commands/run_event_hooks.toml new file mode 100644 index 0000000000..f610103206 --- /dev/null +++ b/plugins/hooks/permissions/autogenerated/commands/run_event_hooks.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-run-event-hooks" +description = "Enables the run_event_hooks command without any pre-configured scope." +commands.allow = ["run_event_hooks"] + +[[permission]] +identifier = "deny-run-event-hooks" +description = "Denies the run_event_hooks command without any pre-configured scope." +commands.deny = ["run_event_hooks"] diff --git a/plugins/hooks/permissions/autogenerated/reference.md b/plugins/hooks/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..58746180be --- /dev/null +++ b/plugins/hooks/permissions/autogenerated/reference.md @@ -0,0 +1,43 @@ +## Default Permission + +Default permissions for the plugin + +#### This default permission set includes the following: + +- `allow-run-event-hooks` + +## Permission Table + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`hooks:allow-run-event-hooks` + + + +Enables the run_event_hooks command without any pre-configured scope. + +
+ +`hooks:deny-run-event-hooks` + + + +Denies the run_event_hooks command without any pre-configured scope. + +
diff --git a/plugins/hooks/permissions/default.toml b/plugins/hooks/permissions/default.toml new file mode 100644 index 0000000000..64584a5241 --- /dev/null +++ b/plugins/hooks/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the plugin" +permissions = ["allow-run-event-hooks"] diff --git a/plugins/hooks/permissions/schemas/schema.json b/plugins/hooks/permissions/schemas/schema.json new file mode 100644 index 0000000000..cc9e0bfea1 --- /dev/null +++ b/plugins/hooks/permissions/schemas/schema.json @@ -0,0 +1,318 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the run_event_hooks command without any pre-configured scope.", + "type": "string", + "const": "allow-run-event-hooks", + "markdownDescription": "Enables the run_event_hooks command without any pre-configured scope." + }, + { + "description": "Denies the run_event_hooks command without any pre-configured scope.", + "type": "string", + "const": "deny-run-event-hooks", + "markdownDescription": "Denies the run_event_hooks command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-run-event-hooks`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-run-event-hooks`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/hooks/src/commands.rs b/plugins/hooks/src/commands.rs new file mode 100644 index 0000000000..45743a1bc2 --- /dev/null +++ b/plugins/hooks/src/commands.rs @@ -0,0 +1,10 @@ +use crate::{event::HookEvent, HooksPluginExt}; + +#[tauri::command] +#[specta::specta] +pub(crate) async fn run_event_hooks( + app: tauri::AppHandle, + event: HookEvent, +) -> Result<(), String> { + app.run_hooks(event).map_err(|e| e.to_string()) +} diff --git a/plugins/hooks/src/config.rs b/plugins/hooks/src/config.rs new file mode 100644 index 0000000000..036f8b2b03 --- /dev/null +++ b/plugins/hooks/src/config.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// 123 +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +pub struct HooksConfig { + /// 345 + pub version: u8, + /// 678 + #[serde(default)] + pub hooks: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +pub struct HookDefinition { + pub command: String, +} + +impl HooksConfig { + pub fn load(app: &impl tauri::Manager) -> crate::Result { + let path = Self::config_path(app)?; + + if !path.exists() { + return Ok(Self::empty()); + } + + let content = + std::fs::read_to_string(&path).map_err(|e| crate::Error::ConfigLoad(e.to_string()))?; + + let config: HooksConfig = + serde_json::from_str(&content).map_err(|e| crate::Error::ConfigParse(e.to_string()))?; + + if config.version != 0 { + return Err(crate::Error::UnsupportedVersion(config.version)); + } + + Ok(config) + } + + fn config_path(app: &impl tauri::Manager) -> crate::Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| crate::Error::ConfigLoad(e.to_string()))?; + + Ok(app_data_dir.join("hyprnote").join("hooks.json")) + } + + fn empty() -> Self { + Self { + version: 0, + hooks: HashMap::new(), + } + } +} diff --git a/plugins/hooks/src/docs.rs b/plugins/hooks/src/docs.rs new file mode 100644 index 0000000000..35d15d4707 --- /dev/null +++ b/plugins/hooks/src/docs.rs @@ -0,0 +1,294 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use swc_common::{sync::Lrc, FileName, SourceMap, Span}; +use swc_ecma_ast::*; +use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookInfo { + pub name: String, + pub description: Option, + pub args: Vec, +} + +impl HookInfo { + pub fn doc_render(&self) -> String { + let yaml = serde_yaml::to_string(self).unwrap_or_default(); + format!("---\n{}---\n", yaml) + } + + pub fn doc_path(&self) -> String { + format!("{}.mdx", self.name) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgField { + pub name: String, + pub description: Option, + pub type_name: String, +} + +#[derive(Debug, Clone)] +struct TypeDoc { + description: Option, + args: Vec, +} + +pub fn parse_hooks(source_code: &str) -> Result, String> { + let (module, fm) = parse_module(source_code)?; + let jsdoc = JsDocExtractor::new(source_code, &fm); + let type_docs = collect_type_docs(&module, &jsdoc); + Ok(extract_hook_events(&module, &type_docs)) +} + +fn parse_module(source_code: &str) -> Result<(Module, Lrc), String> { + let cm: Lrc = Default::default(); + let fm = cm.new_source_file( + FileName::Custom("bindings.gen.ts".into()).into(), + source_code.to_string(), + ); + + let lexer = Lexer::new( + Syntax::Typescript(TsSyntax { + tsx: false, + decorators: false, + dts: false, + no_early_errors: true, + disallow_ambiguous_jsx_like: true, + }), + Default::default(), + StringInput::from(&*fm), + None, + ); + + let mut parser = Parser::new_from(lexer); + let module = parser + .parse_module() + .map_err(|e| format!("Parse error: {:?}", e))?; + + Ok((module, fm)) +} + +fn collect_type_docs(module: &Module, jsdoc: &JsDocExtractor<'_>) -> HashMap { + let mut docs = HashMap::new(); + + for item in &module.body { + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item { + if let Decl::TsTypeAlias(type_alias) = &export.decl { + let type_name = type_alias.id.sym.to_string(); + let description = jsdoc.for_span(&export.span); + let args = extract_fields(type_alias.type_ann.as_ref(), jsdoc); + + docs.insert(type_name, TypeDoc { description, args }); + } + } + } + + docs +} + +fn extract_hook_events(module: &Module, type_docs: &HashMap) -> Vec { + for item in &module.body { + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item { + if let Decl::TsTypeAlias(type_alias) = &export.decl { + if type_alias.id.sym == "HookEvent" { + return extract_union_variants(&type_alias.type_ann, type_docs); + } + } + } + } + + Vec::new() +} + +fn extract_union_variants( + type_ann: &TsType, + type_docs: &HashMap, +) -> Vec { + let mut hooks = Vec::new(); + + if let TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) = + type_ann + { + for variant in &union.types { + if let Some(hook) = extract_variant_info(variant.as_ref(), type_docs) { + hooks.push(hook); + } + } + } + + hooks +} + +fn extract_variant_info( + type_ann: &TsType, + type_docs: &HashMap, +) -> Option { + if let TsType::TsTypeLit(type_lit) = type_ann { + for member in &type_lit.members { + if let TsTypeElement::TsPropertySignature(prop) = member { + if let Expr::Ident(ident) = &*prop.key { + let hook_name = ident.sym.to_string(); + + let args_type_name = prop + .type_ann + .as_ref() + .and_then(|ta| extract_args_type_name(&ta.type_ann))?; + + let (description, args) = type_docs + .get(&args_type_name) + .map(|doc| (doc.description.clone(), doc.args.clone())) + .unwrap_or((None, Vec::new())); + + return Some(HookInfo { + name: hook_name, + description, + args, + }); + } + } + } + } + + None +} + +fn extract_args_type_name(type_ann: &TsType) -> Option { + if let TsType::TsTypeLit(type_lit) = type_ann { + for member in &type_lit.members { + if let TsTypeElement::TsPropertySignature(prop) = member { + if let Expr::Ident(ident) = &*prop.key { + if ident.sym == "args" { + return prop + .type_ann + .as_ref() + .and_then(|ta| extract_type_name(&ta.type_ann)); + } + } + } + } + } + + None +} + +fn extract_type_name(type_ann: &TsType) -> Option { + if let TsType::TsTypeRef(type_ref) = type_ann { + if let TsEntityName::Ident(ident) = &type_ref.type_name { + return Some(ident.sym.to_string()); + } + } + None +} + +fn extract_fields(type_ann: &TsType, jsdoc: &JsDocExtractor<'_>) -> Vec { + let mut fields = Vec::new(); + + if let TsType::TsTypeLit(type_lit) = type_ann { + for member in &type_lit.members { + if let TsTypeElement::TsPropertySignature(prop) = member { + if let Expr::Ident(ident) = &*prop.key { + let field_name = ident.sym.to_string(); + let description = jsdoc.for_span(&prop.span); + let type_name = prop + .type_ann + .as_ref() + .map(|ta| format_type(&ta.type_ann)) + .unwrap_or_else(|| "unknown".to_string()); + + fields.push(ArgField { + name: field_name, + description, + type_name, + }); + } + } + } + } + + fields +} + +struct JsDocExtractor<'a> { + source: &'a str, + fm: Lrc, +} + +impl<'a> JsDocExtractor<'a> { + fn new(source: &'a str, fm: &Lrc) -> Self { + Self { + source, + fm: fm.clone(), + } + } + + fn for_span(&self, span: &Span) -> Option { + let start_pos = self.fm.start_pos.0 as usize; + let lo = span.lo.0 as usize; + + if lo <= start_pos { + return None; + } + + let relative_pos = lo - start_pos; + let before = &self.source[..relative_pos]; + let end = before.rfind("*/")?; + let start = before[..=end].rfind("/**")?; + + if start + 3 > end { + return None; + } + + if !before[end + 2..].trim().is_empty() { + return None; + } + + format_jsdoc_content(&before[start + 3..end]) + } +} + +fn format_jsdoc_content(block: &str) -> Option { + let mut lines = Vec::new(); + + for line in block.lines() { + let trimmed = line.trim(); + + let content = trimmed + .strip_prefix('*') + .map(|rest| rest.trim()) + .unwrap_or(trimmed) + .trim(); + + if !content.is_empty() { + lines.push(content.to_string()); + } + } + + if lines.is_empty() { + None + } else { + Some(lines.join(" ")) + } +} + +fn format_type(type_ann: &TsType) -> String { + match type_ann { + TsType::TsKeywordType(kw) => match kw.kind { + TsKeywordTypeKind::TsStringKeyword => "string".to_string(), + TsKeywordTypeKind::TsNumberKeyword => "number".to_string(), + TsKeywordTypeKind::TsBooleanKeyword => "boolean".to_string(), + _ => format!("{:?}", kw.kind).to_lowercase(), + }, + TsType::TsTypeRef(type_ref) => { + if let TsEntityName::Ident(ident) = &type_ref.type_name { + ident.sym.to_string() + } else { + "unknown".to_string() + } + } + _ => "unknown".to_string(), + } +} diff --git a/plugins/hooks/src/error.rs b/plugins/hooks/src/error.rs new file mode 100644 index 0000000000..b54520b6c3 --- /dev/null +++ b/plugins/hooks/src/error.rs @@ -0,0 +1,24 @@ +use serde::{ser::Serializer, Serialize}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to load config: {0}")] + ConfigLoad(String), + #[error("failed to parse config: {0}")] + ConfigParse(String), + #[error("unsupported config version: {0}")] + UnsupportedVersion(u8), + #[error("hook execution failed: {0}")] + HookExecution(String), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/hooks/src/event.rs b/plugins/hooks/src/event.rs new file mode 100644 index 0000000000..b805aed5ff --- /dev/null +++ b/plugins/hooks/src/event.rs @@ -0,0 +1,61 @@ +use std::ffi::OsString; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +pub enum HookEvent { + #[specta(rename = "afterListeningStopped")] + AfterListeningStopped { args: AfterListeningStoppedArgs }, + #[specta(rename = "beforeListeningStarted")] + BeforeListeningStarted { args: BeforeListeningStartedArgs }, +} + +impl HookEvent { + pub fn condition_key(&self) -> &'static str { + match self { + HookEvent::AfterListeningStopped { .. } => "afterListeningStopped", + HookEvent::BeforeListeningStarted { .. } => "beforeListeningStarted", + } + } + + pub fn cli_args(&self) -> Vec { + match self { + HookEvent::AfterListeningStopped { args } => args.to_cli_args(), + HookEvent::BeforeListeningStarted { args } => args.to_cli_args(), + } + } +} + +pub trait HookArgs { + fn to_cli_args(&self) -> Vec; +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +/// 123 +pub struct AfterListeningStoppedArgs { + /// 345 + pub session_id: String, +} + +impl HookArgs for AfterListeningStoppedArgs { + fn to_cli_args(&self) -> Vec { + vec![ + OsString::from("--session-id"), + OsString::from(&self.session_id), + ] + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +/// 123 +pub struct BeforeListeningStartedArgs { + /// 345 + pub session_id: String, +} + +impl HookArgs for BeforeListeningStartedArgs { + fn to_cli_args(&self) -> Vec { + vec![ + OsString::from("--session-id"), + OsString::from(&self.session_id), + ] + } +} diff --git a/plugins/hooks/src/ext.rs b/plugins/hooks/src/ext.rs new file mode 100644 index 0000000000..91c1820d13 --- /dev/null +++ b/plugins/hooks/src/ext.rs @@ -0,0 +1,15 @@ +use crate::{event::HookEvent, runner::run_hooks_for_event}; + +pub trait HooksPluginExt { + fn run_hooks(&self, event: HookEvent) -> crate::Result<()>; +} + +impl HooksPluginExt for T +where + R: tauri::Runtime, + T: tauri::Manager, +{ + fn run_hooks(&self, event: HookEvent) -> crate::Result<()> { + run_hooks_for_event(self, event) + } +} diff --git a/plugins/hooks/src/lib.rs b/plugins/hooks/src/lib.rs new file mode 100644 index 0000000000..0eb4ccee42 --- /dev/null +++ b/plugins/hooks/src/lib.rs @@ -0,0 +1,71 @@ +mod commands; +mod config; +mod error; +mod event; +mod ext; +mod runner; + +#[cfg(test)] +mod docs; + +pub use error::{Error, Result}; +pub use ext::*; + +const PLUGIN_NAME: &str = "hooks"; + +fn make_specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::new() + .plugin_name(PLUGIN_NAME) + .commands(tauri_specta::collect_commands![ + commands::run_event_hooks::, + ]) + .typ::() + .error_handling(tauri_specta::ErrorHandlingMode::Result) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + let specta_builder = make_specta_builder(); + + tauri::plugin::Builder::new(PLUGIN_NAME) + .invoke_handler(specta_builder.invoke_handler()) + .setup(|_app, _api| Ok(())) + .build() +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn export() { + export_types(); + export_docs(); + } + + fn export_types() { + make_specta_builder::() + .export( + specta_typescript::Typescript::default() + .header("// @ts-nocheck\n\n") + .formatter(specta_typescript::formatter::prettier) + .bigint(specta_typescript::BigIntExportBehavior::Number), + "./js/bindings.gen.ts", + ) + .unwrap() + } + + fn export_docs() { + let source_code = std::fs::read_to_string("./js/bindings.gen.ts").unwrap(); + let hooks = docs::parse_hooks(&source_code).unwrap(); + assert!(!hooks.is_empty()); + + let output_dir = std::path::Path::new("../../apps/web/content/docs/hooks"); + std::fs::create_dir_all(output_dir).unwrap(); + + for hook in &hooks { + let filepath = output_dir.join(hook.doc_path()); + let content = hook.doc_render(); + std::fs::write(&filepath, content).unwrap(); + } + } +} diff --git a/plugins/hooks/src/runner.rs b/plugins/hooks/src/runner.rs new file mode 100644 index 0000000000..86fcfbd8dc --- /dev/null +++ b/plugins/hooks/src/runner.rs @@ -0,0 +1,61 @@ +use std::ffi::OsString; + +use crate::{config::HooksConfig, event::HookEvent}; + +pub fn run_hooks_for_event( + app: &impl tauri::Manager, + event: HookEvent, +) -> crate::Result<()> { + let config = HooksConfig::load(app)?; + let condition_key = event.condition_key(); + let cli_args = event.cli_args(); + + let Some(hooks) = config.hooks.get(condition_key) else { + return Ok(()); + }; + + let futures: Vec<_> = hooks + .iter() + .map(|hook_def| { + let command = hook_def.command.clone(); + let args = cli_args.clone(); + async move { execute_hook(&command, &args).await } + }) + .collect(); + + tauri::async_runtime::spawn(async move { + let _ = futures_util::future::join_all(futures).await; + }); + + Ok(()) +} + +async fn execute_hook(command: &str, args: &[OsString]) -> crate::Result<()> { + use tokio::process::Command; + + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Err(crate::Error::HookExecution("empty command".to_string())); + } + + let mut cmd = Command::new(parts[0]); + + if parts.len() > 1 { + cmd.args(&parts[1..]); + } + + cmd.args(args); + + let output = cmd.output().await.map_err(|e| { + crate::Error::HookExecution(format!("failed to spawn command '{}': {}", command, e)) + })?; + + if !output.status.success() { + return Err(crate::Error::HookExecution(format!( + "command '{}' exited with status: {}", + command, output.status + ))); + } + + Ok(()) +} diff --git a/plugins/hooks/tsconfig.json b/plugins/hooks/tsconfig.json new file mode 100644 index 0000000000..13b985325d --- /dev/null +++ b/plugins/hooks/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["./js/*.ts"], + "exclude": ["node_modules"] +} diff --git a/plugins/listener/Cargo.toml b/plugins/listener/Cargo.toml index 40bf28bda5..b68f8e564d 100644 --- a/plugins/listener/Cargo.toml +++ b/plugins/listener/Cargo.toml @@ -29,6 +29,7 @@ hypr-vad2 = { workspace = true } owhisper-client = { workspace = true } owhisper-interface = { workspace = true } +tauri-plugin-hooks = { workspace = true } tauri-plugin-local-stt = { workspace = true } tauri-plugin-tray = { workspace = true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74b2b7d9aa..b493f59d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@hypr/plugin-detect': specifier: workspace:* version: link:../../plugins/detect + '@hypr/plugin-hooks': + specifier: workspace:* + version: link:../../plugins/hooks '@hypr/plugin-listener': specifier: workspace:* version: link:../../plugins/listener @@ -1048,6 +1051,12 @@ importers: specifier: ^2.9.0 version: 2.9.0 + plugins/hooks: + dependencies: + '@tauri-apps/api': + specifier: ^2.9.0 + version: 2.9.0 + plugins/listener: dependencies: '@tauri-apps/api': diff --git a/scripts/yabai.sh b/scripts/yabai.sh new file mode 100755 index 0000000000..a9e5cbd1fc --- /dev/null +++ b/scripts/yabai.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +app="" +position="left" + +while [[ $# -gt 0 ]]; do + case $1 in + --app) + app="$2" + shift 2 + ;; + --position) + position="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +if [[ -z "$app" ]]; then + echo "Error: --app is required" + exit 1 +fi + +if [[ "$position" != "left" && "$position" != "right" ]]; then + echo "Error: --position must be 'left' or 'right'" + exit 1 +fi + +window_id=$(yabai -m query --windows | jq -r --arg app "$app" 'map(select(.app | ascii_downcase | contains($app | ascii_downcase))) | .[0].id // empty') + +if [[ -z "$window_id" ]]; then + echo "Error: No window found for app '$app'" + exit 1 +fi + +yabai -m window --focus "$window_id" 2>/dev/null + +grid_x=$([[ "$position" == "left" ]] && echo "0" || echo "1") +yabai -m window "$window_id" --grid 1:2:$grid_x:0:1:1