From f91397c429982e62c0a58e513731e990a1cb691e Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 13:18:35 +0900 Subject: [PATCH 1/9] Add hooks plugin Add a new "hooks" tauri plugin to the workspace to provide a simple ping command and expose its API to the desktop app. - Register tauri-plugin-hooks in the workspace Cargo.toml and desktop package.json/Cargo.toml. - Add the plugin to desktop capabilities and initialize it in the tauri lib.rs plugin list. - Add a new plugins/hooks crate with Cargo.toml, build.rs, Rust source (commands, ext, error, lib) and JS bindings/package.json to generate TypeScript bindings. This change is needed to introduce the new hooks plugin (ping command), wire it into the monorepo workspace and the desktop app, and enable codegen for its TypeScript bindings. v v --- Cargo.lock | 14 + Cargo.toml | 1 + apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 1 + apps/desktop/src-tauri/src/lib.rs | 1 + plugins/hooks/.gitignore | 17 + plugins/hooks/Cargo.toml | 23 ++ plugins/hooks/build.rs | 5 + plugins/hooks/js/bindings.gen.ts | 90 +++++ plugins/hooks/js/index.ts | 1 + plugins/hooks/package.json | 11 + .../autogenerated/commands/ping.toml | 13 + .../permissions/autogenerated/reference.md | 43 +++ plugins/hooks/permissions/default.toml | 3 + plugins/hooks/permissions/schemas/schema.json | 318 ++++++++++++++++++ plugins/hooks/src/commands.rs | 10 + plugins/hooks/src/error.rs | 15 + plugins/hooks/src/ext.rs | 9 + plugins/hooks/src/lib.rs | 44 +++ plugins/hooks/tsconfig.json | 5 + 21 files changed, 626 insertions(+) create mode 100644 plugins/hooks/.gitignore create mode 100644 plugins/hooks/Cargo.toml create mode 100644 plugins/hooks/build.rs create mode 100644 plugins/hooks/js/bindings.gen.ts create mode 100644 plugins/hooks/js/index.ts create mode 100644 plugins/hooks/package.json create mode 100644 plugins/hooks/permissions/autogenerated/commands/ping.toml create mode 100644 plugins/hooks/permissions/autogenerated/reference.md create mode 100644 plugins/hooks/permissions/default.toml create mode 100644 plugins/hooks/permissions/schemas/schema.json create mode 100644 plugins/hooks/src/commands.rs create mode 100644 plugins/hooks/src/error.rs create mode 100644 plugins/hooks/src/ext.rs create mode 100644 plugins/hooks/src/lib.rs create mode 100644 plugins/hooks/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index d6dc3876b1..febd904fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3900,6 +3900,7 @@ dependencies = [ "tauri-plugin-detect", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-hooks", "tauri-plugin-http", "tauri-plugin-listener", "tauri-plugin-local-stt", @@ -14634,6 +14635,19 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-hooks" +version = "0.1.0" +dependencies = [ + "serde", + "specta", + "specta-typescript", + "tauri", + "tauri-plugin", + "tauri-specta", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-http" version = "2.5.4" diff --git a/Cargo.toml b/Cargo.toml index 47d0b19437..032f7ec847 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" } 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/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..7137c70967 --- /dev/null +++ b/plugins/hooks/Cargo.toml @@ -0,0 +1,23 @@ +[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 } + +[dependencies] +tauri = { workspace = true, features = ["test"] } +tauri-specta = { workspace = true, features = ["derive", "typescript"] } + +serde = { workspace = true } +specta = { workspace = true } + +thiserror = { workspace = true } diff --git a/plugins/hooks/build.rs b/plugins/hooks/build.rs new file mode 100644 index 0000000000..029861396b --- /dev/null +++ b/plugins/hooks/build.rs @@ -0,0 +1,5 @@ +const COMMANDS: &[&str] = &["ping"]; + +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..a01f9396fc --- /dev/null +++ b/plugins/hooks/js/bindings.gen.ts @@ -0,0 +1,90 @@ +// @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 ping(value: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|ping", { value }) }; +} 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 **/ + + + +/** 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/ping.toml b/plugins/hooks/permissions/autogenerated/commands/ping.toml new file mode 100644 index 0000000000..1d1358807e --- /dev/null +++ b/plugins/hooks/permissions/autogenerated/commands/ping.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-ping" +description = "Enables the ping command without any pre-configured scope." +commands.allow = ["ping"] + +[[permission]] +identifier = "deny-ping" +description = "Denies the ping command without any pre-configured scope." +commands.deny = ["ping"] diff --git a/plugins/hooks/permissions/autogenerated/reference.md b/plugins/hooks/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..f5f293986b --- /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-ping` + +## Permission Table + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`hooks:allow-ping` + + + +Enables the ping command without any pre-configured scope. + +
+ +`hooks:deny-ping` + + + +Denies the ping 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..cc5a76f22e --- /dev/null +++ b/plugins/hooks/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the plugin" +permissions = ["allow-ping"] diff --git a/plugins/hooks/permissions/schemas/schema.json b/plugins/hooks/permissions/schemas/schema.json new file mode 100644 index 0000000000..ac68e129e2 --- /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 ping command without any pre-configured scope.", + "type": "string", + "const": "allow-ping", + "markdownDescription": "Enables the ping command without any pre-configured scope." + }, + { + "description": "Denies the ping command without any pre-configured scope.", + "type": "string", + "const": "deny-ping", + "markdownDescription": "Denies the ping command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`" + } + ] + } + } +} \ 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..db864a5ff6 --- /dev/null +++ b/plugins/hooks/src/commands.rs @@ -0,0 +1,10 @@ +use crate::HooksPluginExt; + +#[tauri::command] +#[specta::specta] +pub(crate) async fn ping( + app: tauri::AppHandle, + value: Option, +) -> Result, String> { + app.ping(value).map_err(|e| e.to_string()) +} diff --git a/plugins/hooks/src/error.rs b/plugins/hooks/src/error.rs new file mode 100644 index 0000000000..6958de1b98 --- /dev/null +++ b/plugins/hooks/src/error.rs @@ -0,0 +1,15 @@ +use serde::{ser::Serializer, Serialize}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error {} + +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/ext.rs b/plugins/hooks/src/ext.rs new file mode 100644 index 0000000000..26df05fffd --- /dev/null +++ b/plugins/hooks/src/ext.rs @@ -0,0 +1,9 @@ +pub trait HooksPluginExt { + fn ping(&self, value: Option) -> Result, crate::Error>; +} + +impl> crate::HooksPluginExt for T { + fn ping(&self, value: Option) -> Result, crate::Error> { + Ok(value) + } +} diff --git a/plugins/hooks/src/lib.rs b/plugins/hooks/src/lib.rs new file mode 100644 index 0000000000..983df041c5 --- /dev/null +++ b/plugins/hooks/src/lib.rs @@ -0,0 +1,44 @@ +mod commands; +mod error; +mod ext; + +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::ping::, + ]) + .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_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() + } +} 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"] +} From 2f0e727f1d8d2cd63f7eaa2af23fff93e2ddbd02 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 13:21:03 +0900 Subject: [PATCH 2/9] add cursor command for plugin gen --- .cursor/commands/add-plugin.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .cursor/commands/add-plugin.md 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. From 207172d2920b90b8627454932202e258c6b134ac Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 13:38:06 +0900 Subject: [PATCH 3/9] first impl Add HooksConfig struct and loader for hooks.json Introduce HooksConfig and HookDefinition types to manage hook configuration stored in hooks.json. Implement loading from the application's data directory, with robust error handling for missing files, read failures, parse errors and unsupported version numbers. Provide helper methods for determining the config path and producing an empty default config. This change was needed to implement the plan's to-dos for hook configuration management so the application can discover and validate hook definitions at runtime without editing the plan file itself. --- Cargo.lock | 3 + plugins/hooks/Cargo.toml | 3 + plugins/hooks/js/bindings.gen.ts | 10 ++- plugins/hooks/src/commands.rs | 12 +++- plugins/hooks/src/config.rs | 63 ++++++++++++++++++ plugins/hooks/src/error.rs | 11 +++- plugins/hooks/src/ext.rs | 107 +++++++++++++++++++++++++++++++ plugins/hooks/src/lib.rs | 2 + 8 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 plugins/hooks/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index febd904fdc..77837692b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14640,12 +14640,15 @@ name = "tauri-plugin-hooks" version = "0.1.0" dependencies = [ "serde", + "serde_json", "specta", "specta-typescript", "tauri", "tauri-plugin", "tauri-specta", "thiserror 2.0.17", + "tokio", + "tracing", ] [[package]] diff --git a/plugins/hooks/Cargo.toml b/plugins/hooks/Cargo.toml index 7137c70967..da550c8d5f 100644 --- a/plugins/hooks/Cargo.toml +++ b/plugins/hooks/Cargo.toml @@ -18,6 +18,9 @@ tauri = { workspace = true, features = ["test"] } tauri-specta = { workspace = true, features = ["derive", "typescript"] } serde = { workspace = true } +serde_json = { workspace = true } specta = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["process"] } +tracing = { workspace = true } diff --git a/plugins/hooks/js/bindings.gen.ts b/plugins/hooks/js/bindings.gen.ts index a01f9396fc..b3a2dc1cc5 100644 --- a/plugins/hooks/js/bindings.gen.ts +++ b/plugins/hooks/js/bindings.gen.ts @@ -14,6 +14,14 @@ async ping(value: string | null) : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async afterListeningStopped(args: AfterListeningStoppedArgs) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|after_listening_stopped", { args }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -27,7 +35,7 @@ async ping(value: string | null) : Promise> { /** user-defined types **/ - +export type AfterListeningStoppedArgs = { session_id: string } /** tauri-specta globals **/ diff --git a/plugins/hooks/src/commands.rs b/plugins/hooks/src/commands.rs index db864a5ff6..6075f49a17 100644 --- a/plugins/hooks/src/commands.rs +++ b/plugins/hooks/src/commands.rs @@ -1,4 +1,4 @@ -use crate::HooksPluginExt; +use crate::{AfterListeningStoppedArgs, HookEvent, HooksPluginExt}; #[tauri::command] #[specta::specta] @@ -8,3 +8,13 @@ pub(crate) async fn ping( ) -> Result, String> { app.ping(value).map_err(|e| e.to_string()) } + +#[tauri::command] +#[specta::specta] +pub(crate) async fn after_listening_stopped( + app: tauri::AppHandle, + args: AfterListeningStoppedArgs, +) -> Result<(), String> { + let event = HookEvent::AfterListeningStopped(args); + 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..19a84a478d --- /dev/null +++ b/plugins/hooks/src/config.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HooksConfig { + pub version: u8, + #[serde(default)] + pub hooks: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +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() { + tracing::debug!("hooks.json not found at {:?}, returning empty config", path); + return Ok(Self::empty()); + } + + let content = std::fs::read_to_string(&path).map_err(|e| { + tracing::error!("failed to read hooks.json from {:?}: {}", path, e); + crate::Error::ConfigLoad(e.to_string()) + })?; + + let config: HooksConfig = serde_json::from_str(&content).map_err(|e| { + tracing::error!("failed to parse hooks.json: {}", e); + crate::Error::ConfigParse(e.to_string()) + })?; + + if config.version != 0 { + tracing::error!( + "unsupported hooks.json version: {} (expected 0)", + config.version + ); + return Err(crate::Error::UnsupportedVersion(config.version)); + } + + tracing::debug!("loaded hooks.json with {} event types", config.hooks.len()); + 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/error.rs b/plugins/hooks/src/error.rs index 6958de1b98..b54520b6c3 100644 --- a/plugins/hooks/src/error.rs +++ b/plugins/hooks/src/error.rs @@ -3,7 +3,16 @@ use serde::{ser::Serializer, Serialize}; pub type Result = std::result::Result; #[derive(Debug, thiserror::Error)] -pub enum 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 diff --git a/plugins/hooks/src/ext.rs b/plugins/hooks/src/ext.rs index 26df05fffd..7547f0d625 100644 --- a/plugins/hooks/src/ext.rs +++ b/plugins/hooks/src/ext.rs @@ -1,9 +1,116 @@ +use std::ffi::OsString; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct AfterListeningStoppedArgs { + pub session_id: String, +} + +impl AfterListeningStoppedArgs { + fn to_cli_args(&self) -> Vec { + vec![ + OsString::from("--session-id"), + OsString::from(&self.session_id), + ] + } +} + +#[derive(Debug, Clone)] +pub enum HookEvent { + AfterListeningStopped(AfterListeningStoppedArgs), +} + +impl HookEvent { + pub fn condition_key(&self) -> &'static str { + match self { + HookEvent::AfterListeningStopped(_) => "afterListeningStopped", + } + } + + pub fn cli_args(&self) -> Vec { + match self { + HookEvent::AfterListeningStopped(args) => args.to_cli_args(), + } + } +} + pub trait HooksPluginExt { fn ping(&self, value: Option) -> Result, crate::Error>; + fn run_hooks(&self, event: HookEvent) -> crate::Result<()>; } impl> crate::HooksPluginExt for T { fn ping(&self, value: Option) -> Result, crate::Error> { Ok(value) } + + fn run_hooks(&self, event: HookEvent) -> crate::Result<()> { + let config = crate::config::HooksConfig::load(self)?; + + let condition_key = event.condition_key(); + let cli_args = event.cli_args(); + + let hooks = config.hooks.get(condition_key); + + if let Some(hooks) = hooks { + tracing::debug!( + "running {} hook(s) for event: {}", + hooks.len(), + condition_key + ); + + for hook_def in hooks { + let command = hook_def.command.clone(); + let args = cli_args.clone(); + + tauri::async_runtime::spawn(async move { + let result = execute_hook(&command, &args).await; + if let Err(e) = result { + tracing::error!("hook execution failed for command '{}': {}", command, e); + } + }); + } + } else { + tracing::debug!("no hooks configured for event: {}", condition_key); + } + + 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 program = parts[0]; + let mut cmd = Command::new(program); + + if parts.len() > 1 { + cmd.args(&parts[1..]); + } + + cmd.args(args); + + tracing::debug!("executing hook: {:?} with args: {:?}", command, args); + + let output = cmd.output().await.map_err(|e| { + crate::Error::HookExecution(format!("failed to spawn command '{}': {}", command, e)) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "hook command '{}' exited with non-zero status: {}. stderr: {}", + command, + output.status, + stderr + ); + } else { + tracing::debug!("hook command '{}' completed successfully", command); + } + + Ok(()) } diff --git a/plugins/hooks/src/lib.rs b/plugins/hooks/src/lib.rs index 983df041c5..67d260b8a1 100644 --- a/plugins/hooks/src/lib.rs +++ b/plugins/hooks/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod config; mod error; mod ext; @@ -12,6 +13,7 @@ fn make_specta_builder() -> tauri_specta::Builder { .plugin_name(PLUGIN_NAME) .commands(tauri_specta::collect_commands![ commands::ping::, + commands::after_listening_stopped::, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) } From fed24d6f60e2a285dd4dd1cd275b53448704bb65 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 13:38:12 +0900 Subject: [PATCH 4/9] initial docs --- apps/web/content/docs/hooks.mdx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/web/content/docs/hooks.mdx diff --git a/apps/web/content/docs/hooks.mdx b/apps/web/content/docs/hooks.mdx new file mode 100644 index 0000000000..5bf8b8070f --- /dev/null +++ b/apps/web/content/docs/hooks.mdx @@ -0,0 +1,29 @@ +# Hooks + +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 +``` From 35c6984477502badf1ac11e93403476b66482cf3 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 13:57:55 +0900 Subject: [PATCH 5/9] update plugin implementation Re-add hooks runner and execution logic Restore the hooks runner that was accidentally removed and reapply hooks handling for events. The new runner loads HooksConfig, looks up hooks for an event, and spawns asynchronous tasks to execute each hook so hook execution does not block the main thread. Also add execute_hook which splits a command string, spawns a tokio process with the configured args, forwards CLI args, and returns an error if the command fails. This ensures hooks are executed reliably and failures are reported. --- Cargo.lock | 2 +- plugins/hooks/Cargo.toml | 2 +- plugins/hooks/build.rs | 2 +- plugins/hooks/js/bindings.gen.ts | 8 -- .../commands/after_listening_stopped.toml | 13 ++ .../autogenerated/commands/ping.toml | 13 -- .../permissions/autogenerated/reference.md | 8 +- plugins/hooks/permissions/schemas/schema.json | 12 +- plugins/hooks/src/commands.rs | 11 +- plugins/hooks/src/config.rs | 18 +-- plugins/hooks/src/event.rs | 38 ++++++ plugins/hooks/src/ext.rs | 115 ++---------------- plugins/hooks/src/lib.rs | 3 +- plugins/hooks/src/runner.rs | 61 ++++++++++ 14 files changed, 139 insertions(+), 167 deletions(-) create mode 100644 plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml delete mode 100644 plugins/hooks/permissions/autogenerated/commands/ping.toml create mode 100644 plugins/hooks/src/event.rs create mode 100644 plugins/hooks/src/runner.rs diff --git a/Cargo.lock b/Cargo.lock index 77837692b7..01b2348abf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14639,6 +14639,7 @@ dependencies = [ name = "tauri-plugin-hooks" version = "0.1.0" dependencies = [ + "futures-util", "serde", "serde_json", "specta", @@ -14648,7 +14649,6 @@ dependencies = [ "tauri-specta", "thiserror 2.0.17", "tokio", - "tracing", ] [[package]] diff --git a/plugins/hooks/Cargo.toml b/plugins/hooks/Cargo.toml index da550c8d5f..8488eba9e9 100644 --- a/plugins/hooks/Cargo.toml +++ b/plugins/hooks/Cargo.toml @@ -23,4 +23,4 @@ specta = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["process"] } -tracing = { workspace = true } +futures-util = { workspace = true } diff --git a/plugins/hooks/build.rs b/plugins/hooks/build.rs index 029861396b..0e789b32ba 100644 --- a/plugins/hooks/build.rs +++ b/plugins/hooks/build.rs @@ -1,4 +1,4 @@ -const COMMANDS: &[&str] = &["ping"]; +const COMMANDS: &[&str] = &["after_listening_stopped"]; fn main() { tauri_plugin::Builder::new(COMMANDS).build(); diff --git a/plugins/hooks/js/bindings.gen.ts b/plugins/hooks/js/bindings.gen.ts index b3a2dc1cc5..fccc36b8c7 100644 --- a/plugins/hooks/js/bindings.gen.ts +++ b/plugins/hooks/js/bindings.gen.ts @@ -7,14 +7,6 @@ export const commands = { -async ping(value: string | null) : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|ping", { value }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, async afterListeningStopped(args: AfterListeningStoppedArgs) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|after_listening_stopped", { args }) }; diff --git a/plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml b/plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml new file mode 100644 index 0000000000..f2abc6f3a8 --- /dev/null +++ b/plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-after-listening-stopped" +description = "Enables the after_listening_stopped command without any pre-configured scope." +commands.allow = ["after_listening_stopped"] + +[[permission]] +identifier = "deny-after-listening-stopped" +description = "Denies the after_listening_stopped command without any pre-configured scope." +commands.deny = ["after_listening_stopped"] diff --git a/plugins/hooks/permissions/autogenerated/commands/ping.toml b/plugins/hooks/permissions/autogenerated/commands/ping.toml deleted file mode 100644 index 1d1358807e..0000000000 --- a/plugins/hooks/permissions/autogenerated/commands/ping.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-ping" -description = "Enables the ping command without any pre-configured scope." -commands.allow = ["ping"] - -[[permission]] -identifier = "deny-ping" -description = "Denies the ping command without any pre-configured scope." -commands.deny = ["ping"] diff --git a/plugins/hooks/permissions/autogenerated/reference.md b/plugins/hooks/permissions/autogenerated/reference.md index f5f293986b..02841c4943 100644 --- a/plugins/hooks/permissions/autogenerated/reference.md +++ b/plugins/hooks/permissions/autogenerated/reference.md @@ -18,12 +18,12 @@ Default permissions for the plugin -`hooks:allow-ping` +`hooks:allow-after-listening-stopped` -Enables the ping command without any pre-configured scope. +Enables the after_listening_stopped command without any pre-configured scope. @@ -31,12 +31,12 @@ Enables the ping command without any pre-configured scope. -`hooks:deny-ping` +`hooks:deny-after-listening-stopped` -Denies the ping command without any pre-configured scope. +Denies the after_listening_stopped command without any pre-configured scope. diff --git a/plugins/hooks/permissions/schemas/schema.json b/plugins/hooks/permissions/schemas/schema.json index ac68e129e2..47fa044b88 100644 --- a/plugins/hooks/permissions/schemas/schema.json +++ b/plugins/hooks/permissions/schemas/schema.json @@ -295,16 +295,16 @@ "type": "string", "oneOf": [ { - "description": "Enables the ping command without any pre-configured scope.", + "description": "Enables the after_listening_stopped command without any pre-configured scope.", "type": "string", - "const": "allow-ping", - "markdownDescription": "Enables the ping command without any pre-configured scope." + "const": "allow-after-listening-stopped", + "markdownDescription": "Enables the after_listening_stopped command without any pre-configured scope." }, { - "description": "Denies the ping command without any pre-configured scope.", + "description": "Denies the after_listening_stopped command without any pre-configured scope.", "type": "string", - "const": "deny-ping", - "markdownDescription": "Denies the ping command without any pre-configured scope." + "const": "deny-after-listening-stopped", + "markdownDescription": "Denies the after_listening_stopped command without any pre-configured scope." }, { "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`", diff --git a/plugins/hooks/src/commands.rs b/plugins/hooks/src/commands.rs index 6075f49a17..765ea1572d 100644 --- a/plugins/hooks/src/commands.rs +++ b/plugins/hooks/src/commands.rs @@ -1,13 +1,4 @@ -use crate::{AfterListeningStoppedArgs, HookEvent, HooksPluginExt}; - -#[tauri::command] -#[specta::specta] -pub(crate) async fn ping( - app: tauri::AppHandle, - value: Option, -) -> Result, String> { - app.ping(value).map_err(|e| e.to_string()) -} +use crate::{event::AfterListeningStoppedArgs, event::HookEvent, HooksPluginExt}; #[tauri::command] #[specta::specta] diff --git a/plugins/hooks/src/config.rs b/plugins/hooks/src/config.rs index 19a84a478d..c6353c0187 100644 --- a/plugins/hooks/src/config.rs +++ b/plugins/hooks/src/config.rs @@ -19,29 +19,19 @@ impl HooksConfig { let path = Self::config_path(app)?; if !path.exists() { - tracing::debug!("hooks.json not found at {:?}, returning empty config", path); return Ok(Self::empty()); } - let content = std::fs::read_to_string(&path).map_err(|e| { - tracing::error!("failed to read hooks.json from {:?}: {}", path, e); - crate::Error::ConfigLoad(e.to_string()) - })?; + 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| { - tracing::error!("failed to parse hooks.json: {}", e); - crate::Error::ConfigParse(e.to_string()) - })?; + let config: HooksConfig = + serde_json::from_str(&content).map_err(|e| crate::Error::ConfigParse(e.to_string()))?; if config.version != 0 { - tracing::error!( - "unsupported hooks.json version: {} (expected 0)", - config.version - ); return Err(crate::Error::UnsupportedVersion(config.version)); } - tracing::debug!("loaded hooks.json with {} event types", config.hooks.len()); Ok(config) } diff --git a/plugins/hooks/src/event.rs b/plugins/hooks/src/event.rs new file mode 100644 index 0000000000..618dcf251e --- /dev/null +++ b/plugins/hooks/src/event.rs @@ -0,0 +1,38 @@ +use std::ffi::OsString; + +pub trait HookArgs { + fn to_cli_args(&self) -> Vec; +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct AfterListeningStoppedArgs { + 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)] +pub enum HookEvent { + AfterListeningStopped(AfterListeningStoppedArgs), +} + +impl HookEvent { + pub fn condition_key(&self) -> &'static str { + match self { + HookEvent::AfterListeningStopped(_) => "afterListeningStopped", + } + } + + pub fn cli_args(&self) -> Vec { + match self { + HookEvent::AfterListeningStopped(args) => args.to_cli_args(), + } + } +} diff --git a/plugins/hooks/src/ext.rs b/plugins/hooks/src/ext.rs index 7547f0d625..91c1820d13 100644 --- a/plugins/hooks/src/ext.rs +++ b/plugins/hooks/src/ext.rs @@ -1,116 +1,15 @@ -use std::ffi::OsString; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] -pub struct AfterListeningStoppedArgs { - pub session_id: String, -} - -impl AfterListeningStoppedArgs { - fn to_cli_args(&self) -> Vec { - vec![ - OsString::from("--session-id"), - OsString::from(&self.session_id), - ] - } -} - -#[derive(Debug, Clone)] -pub enum HookEvent { - AfterListeningStopped(AfterListeningStoppedArgs), -} - -impl HookEvent { - pub fn condition_key(&self) -> &'static str { - match self { - HookEvent::AfterListeningStopped(_) => "afterListeningStopped", - } - } - - pub fn cli_args(&self) -> Vec { - match self { - HookEvent::AfterListeningStopped(args) => args.to_cli_args(), - } - } -} +use crate::{event::HookEvent, runner::run_hooks_for_event}; pub trait HooksPluginExt { - fn ping(&self, value: Option) -> Result, crate::Error>; fn run_hooks(&self, event: HookEvent) -> crate::Result<()>; } -impl> crate::HooksPluginExt for T { - fn ping(&self, value: Option) -> Result, crate::Error> { - Ok(value) - } - +impl HooksPluginExt for T +where + R: tauri::Runtime, + T: tauri::Manager, +{ fn run_hooks(&self, event: HookEvent) -> crate::Result<()> { - let config = crate::config::HooksConfig::load(self)?; - - let condition_key = event.condition_key(); - let cli_args = event.cli_args(); - - let hooks = config.hooks.get(condition_key); - - if let Some(hooks) = hooks { - tracing::debug!( - "running {} hook(s) for event: {}", - hooks.len(), - condition_key - ); - - for hook_def in hooks { - let command = hook_def.command.clone(); - let args = cli_args.clone(); - - tauri::async_runtime::spawn(async move { - let result = execute_hook(&command, &args).await; - if let Err(e) = result { - tracing::error!("hook execution failed for command '{}': {}", command, e); - } - }); - } - } else { - tracing::debug!("no hooks configured for event: {}", condition_key); - } - - 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 program = parts[0]; - let mut cmd = Command::new(program); - - if parts.len() > 1 { - cmd.args(&parts[1..]); - } - - cmd.args(args); - - tracing::debug!("executing hook: {:?} with args: {:?}", command, args); - - let output = cmd.output().await.map_err(|e| { - crate::Error::HookExecution(format!("failed to spawn command '{}': {}", command, e)) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::warn!( - "hook command '{}' exited with non-zero status: {}. stderr: {}", - command, - output.status, - stderr - ); - } else { - tracing::debug!("hook command '{}' completed successfully", command); + run_hooks_for_event(self, event) } - - Ok(()) } diff --git a/plugins/hooks/src/lib.rs b/plugins/hooks/src/lib.rs index 67d260b8a1..adc6e22114 100644 --- a/plugins/hooks/src/lib.rs +++ b/plugins/hooks/src/lib.rs @@ -1,7 +1,9 @@ mod commands; mod config; mod error; +mod event; mod ext; +mod runner; pub use error::{Error, Result}; pub use ext::*; @@ -12,7 +14,6 @@ fn make_specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() .plugin_name(PLUGIN_NAME) .commands(tauri_specta::collect_commands![ - commands::ping::, commands::after_listening_stopped::, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) 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(()) +} From ffe8c4ba46a8f63ac4cb57ff728d9d282dfff5d0 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 14:27:33 +0900 Subject: [PATCH 6/9] docs generation v --- Cargo.lock | 260 ++++++++++++++- .../docs/hooks/afterListeningStopped.mdx | 8 + .../docs/hooks/beforeListeningStarted.mdx | 8 + plugins/hooks/Cargo.toml | 6 +- plugins/hooks/js/bindings.gen.ts | 38 ++- plugins/hooks/src/commands.rs | 15 +- plugins/hooks/src/config.rs | 7 +- plugins/hooks/src/docs.rs | 295 ++++++++++++++++++ plugins/hooks/src/event.rs | 49 ++- plugins/hooks/src/lib.rs | 25 ++ 10 files changed, 691 insertions(+), 20 deletions(-) create mode 100644 apps/web/content/docs/hooks/afterListeningStopped.mdx create mode 100644 apps/web/content/docs/hooks/beforeListeningStarted.mdx create mode 100644 plugins/hooks/src/docs.rs diff --git a/Cargo.lock b/Cargo.lock index 01b2348abf..94151de400 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" @@ -4735,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" @@ -7005,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" @@ -7705,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" @@ -9360,6 +9435,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -10016,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" @@ -11246,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" @@ -13578,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" @@ -13752,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" @@ -13805,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" @@ -13916,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" @@ -14644,6 +14883,9 @@ dependencies = [ "serde_json", "specta", "specta-typescript", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", "tauri", "tauri-plugin", "tauri-specta", @@ -16434,6 +16676,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" @@ -16726,6 +16978,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/apps/web/content/docs/hooks/afterListeningStopped.mdx b/apps/web/content/docs/hooks/afterListeningStopped.mdx new file mode 100644 index 0000000000..ddb5ec3adf --- /dev/null +++ b/apps/web/content/docs/hooks/afterListeningStopped.mdx @@ -0,0 +1,8 @@ +--- +name: afterListeningStopped +description: 123 +args: + - name: session_id + type: string + description: 345 +--- diff --git a/apps/web/content/docs/hooks/beforeListeningStarted.mdx b/apps/web/content/docs/hooks/beforeListeningStarted.mdx new file mode 100644 index 0000000000..2c4a46429a --- /dev/null +++ b/apps/web/content/docs/hooks/beforeListeningStarted.mdx @@ -0,0 +1,8 @@ +--- +name: beforeListeningStarted +description: 123 +args: + - name: session_id + type: string + description: 345 +--- diff --git a/plugins/hooks/Cargo.toml b/plugins/hooks/Cargo.toml index 8488eba9e9..ec359abcdc 100644 --- a/plugins/hooks/Cargo.toml +++ b/plugins/hooks/Cargo.toml @@ -13,6 +13,10 @@ 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"] } @@ -21,6 +25,6 @@ serde = { workspace = true } serde_json = { workspace = true } specta = { workspace = true } +futures-util = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["process"] } -futures-util = { workspace = true } diff --git a/plugins/hooks/js/bindings.gen.ts b/plugins/hooks/js/bindings.gen.ts index fccc36b8c7..4456549226 100644 --- a/plugins/hooks/js/bindings.gen.ts +++ b/plugins/hooks/js/bindings.gen.ts @@ -7,6 +7,14 @@ export const commands = { +async beforeListeningStarted(args: BeforeListeningStartedArgs) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|before_listening_started", { args }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async afterListeningStopped(args: AfterListeningStoppedArgs) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|after_listening_stopped", { args }) }; @@ -27,7 +35,35 @@ async afterListeningStopped(args: AfterListeningStoppedArgs) : Promise } /** tauri-specta globals **/ diff --git a/plugins/hooks/src/commands.rs b/plugins/hooks/src/commands.rs index 765ea1572d..218d3801c2 100644 --- a/plugins/hooks/src/commands.rs +++ b/plugins/hooks/src/commands.rs @@ -1,4 +1,17 @@ -use crate::{event::AfterListeningStoppedArgs, event::HookEvent, HooksPluginExt}; +use crate::{ + event::{AfterListeningStoppedArgs, BeforeListeningStartedArgs, HookEvent}, + HooksPluginExt, +}; + +#[tauri::command] +#[specta::specta] +pub(crate) async fn before_listening_started( + app: tauri::AppHandle, + args: BeforeListeningStartedArgs, +) -> Result<(), String> { + let event = HookEvent::BeforeListeningStarted(args); + app.run_hooks(event).map_err(|e| e.to_string()) +} #[tauri::command] #[specta::specta] diff --git a/plugins/hooks/src/config.rs b/plugins/hooks/src/config.rs index c6353c0187..036f8b2b03 100644 --- a/plugins/hooks/src/config.rs +++ b/plugins/hooks/src/config.rs @@ -2,14 +2,17 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -#[derive(Debug, Clone, Serialize, Deserialize)] +/// 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)] +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] pub struct HookDefinition { pub command: String, } diff --git a/plugins/hooks/src/docs.rs b/plugins/hooks/src/docs.rs new file mode 100644 index 0000000000..988b2db7ff --- /dev/null +++ b/plugins/hooks/src/docs.rs @@ -0,0 +1,295 @@ +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 mut content = String::from("---\n"); + content.push_str(&format!("name: {}\n", self.name)); + + if let Some(desc) = &self.description { + content.push_str(&format!("description: {}\n", desc)); + } + + if !self.args.is_empty() { + content.push_str("args:\n"); + for arg in &self.args { + content.push_str(&format!(" - name: {}\n", arg.name)); + content.push_str(&format!(" type: {}\n", arg.type_name)); + if let Some(desc) = &arg.description { + content.push_str(&format!(" description: {}\n", desc)); + } + } + } + + content.push_str("---\n"); + content + } + + 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_hooks(&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_hooks(module: &Module, type_docs: &HashMap) -> Vec { + command_methods(module) + .into_iter() + .filter_map(|method| hook_from_method(method, type_docs)) + .collect() +} + +fn command_methods<'a>(module: &'a Module) -> Vec<&'a MethodProp> { + let mut methods = Vec::new(); + + for item in &module.body { + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item { + if let Decl::Var(var_decl) = &export.decl { + for decl in &var_decl.decls { + if let (Pat::Ident(ident), Some(init)) = (&decl.name, &decl.init) { + if ident.id.sym == "commands" { + if let Expr::Object(obj) = &**init { + for prop in &obj.props { + if let PropOrSpread::Prop(prop) = prop { + if let Prop::Method(method) = &**prop { + methods.push(method); + } + } + } + } + } + } + } + } + } + } + + methods +} + +fn hook_from_method(method: &MethodProp, type_docs: &HashMap) -> Option { + let hook_name = if let PropName::Ident(method_name) = &method.key { + method_name.sym.to_string() + } else { + return None; + }; + + let (description, args) = method_arg_type_name(method) + .and_then(|type_name| type_docs.get(&type_name)) + .map(|doc| (doc.description.clone(), doc.args.clone())) + .unwrap_or((None, Vec::new())); + + Some(HookInfo { + name: hook_name, + description, + args, + }) +} + +fn method_arg_type_name(method: &MethodProp) -> Option { + method + .function + .params + .first() + .and_then(|param| match ¶m.pat { + Pat::Ident(ident) => ident + .type_ann + .as_ref() + .and_then(|type_ann| extract_type_name(&type_ann.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/event.rs b/plugins/hooks/src/event.rs index 618dcf251e..d9df736c66 100644 --- a/plugins/hooks/src/event.rs +++ b/plugins/hooks/src/event.rs @@ -1,11 +1,35 @@ use std::ffi::OsString; +#[derive(Debug, Clone)] +pub enum HookEvent { + AfterListeningStopped(AfterListeningStoppedArgs), + BeforeListeningStarted(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, } @@ -18,21 +42,18 @@ impl HookArgs for AfterListeningStoppedArgs { } } -#[derive(Debug, Clone)] -pub enum HookEvent { - AfterListeningStopped(AfterListeningStoppedArgs), +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +/// 123 +pub struct BeforeListeningStartedArgs { + /// 345 + pub session_id: String, } -impl HookEvent { - pub fn condition_key(&self) -> &'static str { - match self { - HookEvent::AfterListeningStopped(_) => "afterListeningStopped", - } - } - - pub fn cli_args(&self) -> Vec { - match self { - HookEvent::AfterListeningStopped(args) => args.to_cli_args(), - } +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/lib.rs b/plugins/hooks/src/lib.rs index adc6e22114..c35bb20d57 100644 --- a/plugins/hooks/src/lib.rs +++ b/plugins/hooks/src/lib.rs @@ -5,6 +5,9 @@ mod event; mod ext; mod runner; +#[cfg(test)] +mod docs; + pub use error::{Error, Result}; pub use ext::*; @@ -14,8 +17,10 @@ fn make_specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() .plugin_name(PLUGIN_NAME) .commands(tauri_specta::collect_commands![ + commands::before_listening_started::, commands::after_listening_stopped::, ]) + .typ::() .error_handling(tauri_specta::ErrorHandlingMode::Result) } @@ -33,6 +38,11 @@ mod test { use super::*; #[test] + fn export() { + export_types(); + export_docs(); + } + fn export_types() { make_specta_builder::() .export( @@ -44,4 +54,19 @@ mod test { ) .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(); + } + } } From b4e2c8393281a58e6aec04a32d9bee0f28900cdc Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 14:48:49 +0900 Subject: [PATCH 7/9] update lock --- pnpm-lock.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) 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': From ccaceae7d50d78d377153928cc98dd4f60089bcb Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 16:03:36 +0900 Subject: [PATCH 8/9] integrate hook for the first time --- Cargo.lock | 1 + .../src/store/zustand/listener/general.ts | 23 +++- apps/web/src/routeTree.gen.ts | 21 --- plugins/hooks/build.rs | 2 +- plugins/hooks/js/bindings.gen.ts | 13 +- .../commands/after_listening_stopped.toml | 13 -- .../commands/run_event_hooks.toml | 13 ++ .../permissions/autogenerated/reference.md | 10 +- plugins/hooks/permissions/default.toml | 2 +- plugins/hooks/permissions/schemas/schema.json | 16 +-- plugins/hooks/src/commands.rs | 20 +-- plugins/hooks/src/docs.rs | 129 ++++++++++-------- plugins/hooks/src/event.rs | 16 ++- plugins/hooks/src/lib.rs | 3 +- plugins/listener/Cargo.toml | 1 + scripts/yabai.sh | 42 ++++++ 16 files changed, 183 insertions(+), 142 deletions(-) delete mode 100644 plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml create mode 100644 plugins/hooks/permissions/autogenerated/commands/run_event_hooks.toml create mode 100755 scripts/yabai.sh diff --git a/Cargo.lock b/Cargo.lock index 94151de400..c65415d074 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14947,6 +14947,7 @@ dependencies = [ "strum 0.26.3", "tauri", "tauri-plugin", + "tauri-plugin-hooks", "tauri-plugin-local-stt", "tauri-plugin-tray", "tauri-specta", 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/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/plugins/hooks/build.rs b/plugins/hooks/build.rs index 0e789b32ba..586922f688 100644 --- a/plugins/hooks/build.rs +++ b/plugins/hooks/build.rs @@ -1,4 +1,4 @@ -const COMMANDS: &[&str] = &["after_listening_stopped"]; +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 index 4456549226..64e84a71a8 100644 --- a/plugins/hooks/js/bindings.gen.ts +++ b/plugins/hooks/js/bindings.gen.ts @@ -7,17 +7,9 @@ export const commands = { -async beforeListeningStarted(args: BeforeListeningStartedArgs) : Promise> { +async runEventHooks(event: HookEvent) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|before_listening_started", { args }) }; -} catch (e) { - if(e instanceof Error) throw e; - else return { status: "error", error: e as any }; -} -}, -async afterListeningStopped(args: AfterListeningStoppedArgs) : Promise> { - try { - return { status: "ok", data: await TAURI_INVOKE("plugin:hooks|after_listening_stopped", { args }) }; + 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 }; @@ -52,6 +44,7 @@ export type BeforeListeningStartedArgs = { */ session_id: string } export type HookDefinition = { command: string } +export type HookEvent = { afterListeningStopped: { args: AfterListeningStoppedArgs } } | { beforeListeningStarted: { args: BeforeListeningStartedArgs } } /** * 123 */ diff --git a/plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml b/plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml deleted file mode 100644 index f2abc6f3a8..0000000000 --- a/plugins/hooks/permissions/autogenerated/commands/after_listening_stopped.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-after-listening-stopped" -description = "Enables the after_listening_stopped command without any pre-configured scope." -commands.allow = ["after_listening_stopped"] - -[[permission]] -identifier = "deny-after-listening-stopped" -description = "Denies the after_listening_stopped command without any pre-configured scope." -commands.deny = ["after_listening_stopped"] 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 index 02841c4943..58746180be 100644 --- a/plugins/hooks/permissions/autogenerated/reference.md +++ b/plugins/hooks/permissions/autogenerated/reference.md @@ -4,7 +4,7 @@ Default permissions for the plugin #### This default permission set includes the following: -- `allow-ping` +- `allow-run-event-hooks` ## Permission Table @@ -18,12 +18,12 @@ Default permissions for the plugin -`hooks:allow-after-listening-stopped` +`hooks:allow-run-event-hooks` -Enables the after_listening_stopped command without any pre-configured scope. +Enables the run_event_hooks command without any pre-configured scope. @@ -31,12 +31,12 @@ Enables the after_listening_stopped command without any pre-configured scope. -`hooks:deny-after-listening-stopped` +`hooks:deny-run-event-hooks` -Denies the after_listening_stopped command without any pre-configured scope. +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 index cc5a76f22e..64584a5241 100644 --- a/plugins/hooks/permissions/default.toml +++ b/plugins/hooks/permissions/default.toml @@ -1,3 +1,3 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-ping"] +permissions = ["allow-run-event-hooks"] diff --git a/plugins/hooks/permissions/schemas/schema.json b/plugins/hooks/permissions/schemas/schema.json index 47fa044b88..cc9e0bfea1 100644 --- a/plugins/hooks/permissions/schemas/schema.json +++ b/plugins/hooks/permissions/schemas/schema.json @@ -295,22 +295,22 @@ "type": "string", "oneOf": [ { - "description": "Enables the after_listening_stopped command without any pre-configured scope.", + "description": "Enables the run_event_hooks command without any pre-configured scope.", "type": "string", - "const": "allow-after-listening-stopped", - "markdownDescription": "Enables the after_listening_stopped command without any pre-configured scope." + "const": "allow-run-event-hooks", + "markdownDescription": "Enables the run_event_hooks command without any pre-configured scope." }, { - "description": "Denies the after_listening_stopped command without any pre-configured scope.", + "description": "Denies the run_event_hooks command without any pre-configured scope.", "type": "string", - "const": "deny-after-listening-stopped", - "markdownDescription": "Denies the after_listening_stopped command without any pre-configured scope." + "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-ping`", + "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-ping`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-run-event-hooks`" } ] } diff --git a/plugins/hooks/src/commands.rs b/plugins/hooks/src/commands.rs index 218d3801c2..45743a1bc2 100644 --- a/plugins/hooks/src/commands.rs +++ b/plugins/hooks/src/commands.rs @@ -1,24 +1,10 @@ -use crate::{ - event::{AfterListeningStoppedArgs, BeforeListeningStartedArgs, HookEvent}, - HooksPluginExt, -}; +use crate::{event::HookEvent, HooksPluginExt}; #[tauri::command] #[specta::specta] -pub(crate) async fn before_listening_started( +pub(crate) async fn run_event_hooks( app: tauri::AppHandle, - args: BeforeListeningStartedArgs, + event: HookEvent, ) -> Result<(), String> { - let event = HookEvent::BeforeListeningStarted(args); - app.run_hooks(event).map_err(|e| e.to_string()) -} - -#[tauri::command] -#[specta::specta] -pub(crate) async fn after_listening_stopped( - app: tauri::AppHandle, - args: AfterListeningStoppedArgs, -) -> Result<(), String> { - let event = HookEvent::AfterListeningStopped(args); app.run_hooks(event).map_err(|e| e.to_string()) } diff --git a/plugins/hooks/src/docs.rs b/plugins/hooks/src/docs.rs index 988b2db7ff..25969ee852 100644 --- a/plugins/hooks/src/docs.rs +++ b/plugins/hooks/src/docs.rs @@ -59,7 +59,7 @@ 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_hooks(&module, &type_docs)) + Ok(extract_hook_events(&module, &type_docs)) } fn parse_module(source_code: &str) -> Result<(Module, Lrc), String> { @@ -108,72 +108,89 @@ fn collect_type_docs(module: &Module, jsdoc: &JsDocExtractor<'_>) -> HashMap) -> Vec { - command_methods(module) - .into_iter() - .filter_map(|method| hook_from_method(method, type_docs)) - .collect() -} - -fn command_methods<'a>(module: &'a Module) -> Vec<&'a MethodProp> { - let mut methods = Vec::new(); - +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::Var(var_decl) = &export.decl { - for decl in &var_decl.decls { - if let (Pat::Ident(ident), Some(init)) = (&decl.name, &decl.init) { - if ident.id.sym == "commands" { - if let Expr::Object(obj) = &**init { - for prop in &obj.props { - if let PropOrSpread::Prop(prop) = prop { - if let Prop::Method(method) = &**prop { - methods.push(method); - } - } - } - } - } - } + if let Decl::TsTypeAlias(type_alias) = &export.decl { + if type_alias.id.sym == "HookEvent" { + return extract_union_variants(&type_alias.type_ann, type_docs); } } } } - methods + Vec::new() } -fn hook_from_method(method: &MethodProp, type_docs: &HashMap) -> Option { - let hook_name = if let PropName::Ident(method_name) = &method.key { - method_name.sym.to_string() - } else { - return None; - }; - - let (description, args) = method_arg_type_name(method) - .and_then(|type_name| type_docs.get(&type_name)) - .map(|doc| (doc.description.clone(), doc.args.clone())) - .unwrap_or((None, Vec::new())); - - Some(HookInfo { - name: hook_name, - description, - args, - }) +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 method_arg_type_name(method: &MethodProp) -> Option { - method - .function - .params - .first() - .and_then(|param| match ¶m.pat { - Pat::Ident(ident) => ident - .type_ann - .as_ref() - .and_then(|type_ann| extract_type_name(&type_ann.type_ann)), - _ => 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 { diff --git a/plugins/hooks/src/event.rs b/plugins/hooks/src/event.rs index d9df736c66..b805aed5ff 100644 --- a/plugins/hooks/src/event.rs +++ b/plugins/hooks/src/event.rs @@ -1,23 +1,25 @@ use std::ffi::OsString; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] pub enum HookEvent { - AfterListeningStopped(AfterListeningStoppedArgs), - BeforeListeningStarted(BeforeListeningStartedArgs), + #[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", + 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(), + HookEvent::AfterListeningStopped { args } => args.to_cli_args(), + HookEvent::BeforeListeningStarted { args } => args.to_cli_args(), } } } diff --git a/plugins/hooks/src/lib.rs b/plugins/hooks/src/lib.rs index c35bb20d57..0eb4ccee42 100644 --- a/plugins/hooks/src/lib.rs +++ b/plugins/hooks/src/lib.rs @@ -17,8 +17,7 @@ fn make_specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() .plugin_name(PLUGIN_NAME) .commands(tauri_specta::collect_commands![ - commands::before_listening_started::, - commands::after_listening_stopped::, + commands::run_event_hooks::, ]) .typ::() .error_handling(tauri_specta::ErrorHandlingMode::Result) 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/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 From 51e68244e2070918e5212dbba5ba5aaa117bbe02 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 19 Nov 2025 16:14:17 +0900 Subject: [PATCH 9/9] render hooks collection to the docs --- Cargo.lock | 1 + Cargo.toml | 1 + apps/web/content-collections.ts | 47 +++++++++++++++- apps/web/content/docs/hooks.mdx | 11 +++- .../docs/hooks/afterListeningStopped.mdx | 8 +-- .../docs/hooks/beforeListeningStarted.mdx | 8 +-- apps/web/src/components/hooks-list.tsx | 54 +++++++++++++++++++ .../web/src/routes/_view/docs/-components.tsx | 2 + plugins/hooks/Cargo.toml | 1 + plugins/hooks/src/docs.rs | 22 +------- 10 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/hooks-list.tsx diff --git a/Cargo.lock b/Cargo.lock index c65415d074..b900daa378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14881,6 +14881,7 @@ dependencies = [ "futures-util", "serde", "serde_json", + "serde_yaml", "specta", "specta-typescript", "swc_common", diff --git a/Cargo.toml b/Cargo.toml index 032f7ec847..ed21df7765 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,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/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 index 5bf8b8070f..9e616234b5 100644 --- a/apps/web/content/docs/hooks.mdx +++ b/apps/web/content/docs/hooks.mdx @@ -1,4 +1,9 @@ -# Hooks +--- +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. @@ -27,3 +32,7 @@ chmod +x "$HOME/Library/Application Support/hyprnote/hooks/demo.sh" 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 index ddb5ec3adf..4cbb6eb4b8 100644 --- a/apps/web/content/docs/hooks/afterListeningStopped.mdx +++ b/apps/web/content/docs/hooks/afterListeningStopped.mdx @@ -1,8 +1,8 @@ --- name: afterListeningStopped -description: 123 +description: '123' args: - - name: session_id - type: string - description: 345 +- 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 index 2c4a46429a..da52a0d463 100644 --- a/apps/web/content/docs/hooks/beforeListeningStarted.mdx +++ b/apps/web/content/docs/hooks/beforeListeningStarted.mdx @@ -1,8 +1,8 @@ --- name: beforeListeningStarted -description: 123 +description: '123' args: - - name: session_id - type: string - description: 345 +- 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/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/Cargo.toml b/plugins/hooks/Cargo.toml index ec359abcdc..35720ffe47 100644 --- a/plugins/hooks/Cargo.toml +++ b/plugins/hooks/Cargo.toml @@ -23,6 +23,7 @@ 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 } diff --git a/plugins/hooks/src/docs.rs b/plugins/hooks/src/docs.rs index 25969ee852..35d15d4707 100644 --- a/plugins/hooks/src/docs.rs +++ b/plugins/hooks/src/docs.rs @@ -15,26 +15,8 @@ pub struct HookInfo { impl HookInfo { pub fn doc_render(&self) -> String { - let mut content = String::from("---\n"); - content.push_str(&format!("name: {}\n", self.name)); - - if let Some(desc) = &self.description { - content.push_str(&format!("description: {}\n", desc)); - } - - if !self.args.is_empty() { - content.push_str("args:\n"); - for arg in &self.args { - content.push_str(&format!(" - name: {}\n", arg.name)); - content.push_str(&format!(" type: {}\n", arg.type_name)); - if let Some(desc) = &arg.description { - content.push_str(&format!(" description: {}\n", desc)); - } - } - } - - content.push_str("---\n"); - content + let yaml = serde_yaml::to_string(self).unwrap_or_default(); + format!("---\n{}---\n", yaml) } pub fn doc_path(&self) -> String {