touchportal_sdk/
lib.rs

1use derive_builder::Builder;
2use hex_color::HexColor;
3use serde::{Deserialize, Serialize};
4use serde_repr::{Deserialize_repr, Serialize_repr};
5
6// root should be single folder without spaces in the name
7// entry.tp -- "description file"
8// https://www.touch-portal.com/api/index.php?section=description_file
9//
10// cargo build --release
11
12pub mod codegen;
13pub use codegen::{export, generate};
14
15pub mod reexport {
16    pub use hex_color::HexColor;
17}
18
19pub fn entry_tp(plugin: &PluginDescription) -> String {
20    serde_json::to_string(plugin).expect("every PluginDescription serializes")
21}
22
23pub mod protocol;
24
25#[cfg(feature = "mock")]
26pub mod mock;
27
28/// Mapping from TouchPortal version to API version.
29#[derive(Debug, Clone, Copy, Deserialize_repr, Serialize_repr)]
30#[non_exhaustive]
31#[repr(u16)]
32pub enum ApiVersion {
33    V2_1 = 1,
34    V2_2 = 2,
35    V2_3 = 3,
36    V3_0 = 4,
37    V3_0_6 = 5,
38    V3_0_11 = 6,
39    V4_0 = 7,
40    V4_1 = 8,
41    V4_2 = 9,
42    V4_3 = 10,
43    V4_5 = 12,
44}
45
46#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
47#[builder(build_fn(validate = "Self::validate"))]
48#[serde(rename_all = "camelCase")]
49pub struct PluginDescription {
50    /// The API version of Touch Portal this plugin is build for.
51    #[serde(alias = "sdk")]
52    api: ApiVersion,
53
54    /// A number representing your own versioning.
55    ///
56    /// Currently this variable is not used by Touch Portal but may be used in the future. This
57    /// should be an integer value. So only whole numbers, no decimals.
58    ///
59    /// This version value will be
60    /// send back after the pairing has been done.
61    version: u16,
62
63    /// This is the name of the Plugin.
64    ///
65    /// This will show in Touch Portal in the settings section "Plug-ins".
66    ///
67    /// (From Touch Portal version 2.2)
68    #[builder(setter(into))]
69    name: String,
70
71    /// This is the unique ID of the Plugin.
72    ///
73    /// Use an id that will only be used by you. So use a prefix for example.
74    #[builder(setter(into))]
75    id: String,
76
77    /// This object is used to specify some configuration options of the plug-in.
78    configuration: PluginConfiguration,
79
80    /// Specify the path of execution here.
81    ///
82    /// You should be aware that it will be passed to the OS process exection service. This means
83    /// you need to be aware of spaces and use absolute paths to your executable.
84    ///
85    /// If you use `%TP_PLUGIN_FOLDER%` in the text here, it will be replaced with the path to the
86    /// base plugins folder. So append your plugins folder name to it as well to access your plugin
87    /// base folder.
88    ///
89    /// This execution will be done when the plugin is loaded in the system and only if it is a
90    /// valid plugin. Use this to start your own service that communicates with the Touch Portal
91    /// plugin system.
92    // needs rename to override rename_all
93    #[serde(rename = "plugin_start_cmd")]
94    #[builder(setter(into))]
95    plugin_start_cmd: String,
96
97    /// This is the same plugin_start_cmd but will only run on a Windows desktop.
98    ///
99    /// If this is specified Touch Portal will not run the default `plugin_start_cmd` when this plug-in is used on Windows but only this entry.
100    ///
101    /// Only available on API version 4 and above.
102    #[serde(rename = "plugin_start_cmd_windows")]
103    #[serde(skip_serializing_if = "Option::is_none")]
104    #[builder(setter(into, strip_option), default)]
105    plugin_start_cmd_windows: Option<String>,
106
107    /// This is the same plugin_start_cmd but will only run on a MacOS desktop.
108    ///
109    /// If this is specified Touch Portal will not run the default `plugin_start_cmd` when this plug-in is used on MacOS but only this entry.
110    ///
111    /// Only available on API version 4 and above.
112    #[serde(rename = "plugin_start_cmd_mac")]
113    #[serde(skip_serializing_if = "Option::is_none")]
114    #[builder(setter(into, strip_option), default)]
115    plugin_start_cmd_mac: Option<String>,
116
117    /// This is the same plugin_start_cmd but will only run on a Linux desktop.
118    ///
119    /// If this is specified Touch Portal will not run the default `plugin_start_cmd` when this plug-in is used on Linux but only this entry.
120    ///
121    /// Only available on API version 4 and above.
122    #[serde(rename = "plugin_start_cmd_linux")]
123    #[serde(skip_serializing_if = "Option::is_none")]
124    #[builder(setter(into, strip_option), default)]
125    plugin_start_cmd_linux: Option<String>,
126
127    /// This is the collection that holds all the action categories.
128    ///
129    /// Categories are used in the action list in Touch Portal.
130    ///
131    /// Each Category must contain at least an item such as an action, an event or an connector. More on this in the category section.
132    #[builder(setter(each(name = "category")), default)]
133    categories: Vec<Category>,
134
135    /// This is the collection that holds all the settings for this plug-in
136    ///
137    /// Only available in API version 3 and above.
138    #[builder(setter(each(name = "setting")), default)]
139    settings: Vec<Setting>,
140
141    /// This description text can be used to add information on the top of the plug-in settings
142    /// page.
143    ///
144    /// You can use this to have a setup guide or important text to show when setting up your
145    /// plug-in.
146    ///
147    /// In general this is normal text but it can be formatted a bit with the following
148    /// functionality:
149    ///
150    /// - `\n` will create a new line.
151    /// - `#` will create a sub header but only if it is the first character on a new line.
152    /// - `1.` can be used to create a list item. It only works if the first characters of the line
153    ///   are a numbers followed by a period.
154    ///
155    /// Only available in API version 10 and above.
156    #[serde(skip_serializing_if = "String::is_empty")]
157    #[builder(setter(into), default)]
158    settings_description: String,
159}
160
161impl PluginDescription {
162    pub fn builder() -> PluginDescriptionBuilder {
163        PluginDescriptionBuilder::default()
164    }
165}
166
167impl PluginDescriptionBuilder {
168    fn validate(&self) -> Result<(), String> {
169        // Check for empty categories
170        for category in self.categories.iter().flatten() {
171            if category.actions.is_empty()
172                && category.events.is_empty()
173                && category.connectors.is_empty()
174                && category.states.is_empty()
175            {
176                return Err(format!(
177                    "category '{}' is empty - categories must contain at least one action, event, connector, or state",
178                    category.id
179                ));
180            }
181        }
182
183        let states = self.categories.iter().flatten().flat_map(|c| &c.states);
184        let states_by_id: HashMap<_, _> = states.map(|s| (&s.id, s)).collect();
185
186        let events = self.categories.iter().flatten().flat_map(|c| &c.events);
187        for event in events {
188            if event.value_state_id.is_empty() {
189                continue;
190            }
191
192            let Some(state) = states_by_id.get(&event.value_state_id) else {
193                return Err(format!(
194                    "event {} references unknown state {}",
195                    event.id, event.value_state_id
196                ));
197            };
198
199            match (&state.kind, &event.value) {
200                (StateType::Choice(state_choices), EventValueType::Choice(event_choices)) => {
201                    if state_choices.choices != event_choices.choices {
202                        return Err(format!(
203                            "event {} references state {}, \
204                            but they have diverging choice-sets",
205                            event.id, event.value_state_id
206                        ));
207                    }
208                }
209                (StateType::Choice(_), EventValueType::Text(_)) => {
210                    return Err(format!(
211                        "event {} is of free-text type, \
212                        but references state {} which is of choice type",
213                        event.id, event.value_state_id
214                    ));
215                }
216                (StateType::Text(_), EventValueType::Choice(_)) => {
217                    return Err(format!(
218                        "event {} is of choice type, \
219                        but references state {} which is of free-text type",
220                        event.id, event.value_state_id
221                    ));
222                }
223                (StateType::Text(_), EventValueType::Text(_)) => {}
224            }
225        }
226
227        // data ids don't need to be unique, but they should not differ in their definition!
228        let mut data_by_id: HashMap<_, Vec<_>> = Default::default();
229        for action in self.categories.iter().flatten().flat_map(|c| &c.actions) {
230            for Data { id, format } in &action.data {
231                data_by_id.entry(id).or_default().push(format);
232            }
233        }
234        for (id, formats) in data_by_id {
235            if formats.len() == 1 {
236                continue;
237            }
238            for &format in &formats[1..] {
239                match (formats[0], format) {
240                    (DataFormat::Text(TextData { initial: _ }), DataFormat::Text(_)) => {}
241                    (
242                        DataFormat::Number(NumberData {
243                            allow_decimals,
244                            min_value,
245                            max_value,
246                            initial: _,
247                        }),
248                        DataFormat::Number(n2),
249                    ) => {
250                        if *allow_decimals != n2.allow_decimals
251                            || *min_value != n2.min_value
252                            || *max_value != n2.max_value
253                        {
254                            return Err(format!(
255                                "data field {id} appears multiple times with different numeric definitions"
256                            ));
257                        }
258                    }
259                    (
260                        DataFormat::Choice(ChoiceData {
261                            initial: _,
262                            value_choices,
263                        }),
264                        DataFormat::Choice(c2),
265                    ) => {
266                        if *value_choices != c2.value_choices {
267                            return Err(format!(
268                                "data field {id} appears multiple times with different choice definitions"
269                            ));
270                        }
271                    }
272                    (
273                        DataFormat::File(FileData {
274                            extensions,
275                            initial: _,
276                        }),
277                        DataFormat::File(f2),
278                    ) => {
279                        if *extensions != f2.extensions {
280                            return Err(format!(
281                                "data field {id} appears multiple times with different file definitions"
282                            ));
283                        }
284                    }
285                    (DataFormat::Switch(SwitchData { initial: _ }), DataFormat::Switch(_))
286                    | (DataFormat::Folder(FolderData { initial: _ }), DataFormat::Folder(_))
287                    | (DataFormat::Color(ColorData { initial: _ }), DataFormat::Color(_)) => {}
288                    (
289                        DataFormat::LowerBound(BoundData {
290                            initial: _,
291                            min_value,
292                            max_value,
293                        }),
294                        DataFormat::LowerBound(b2),
295                    )
296                    | (
297                        DataFormat::UpperBound(BoundData {
298                            initial: _,
299                            min_value,
300                            max_value,
301                        }),
302                        DataFormat::UpperBound(b2),
303                    ) => {
304                        if *min_value != b2.min_value || *max_value != b2.max_value {
305                            return Err(format!(
306                                "data field {id} appears multiple times with different bound definitions"
307                            ));
308                        }
309                    }
310                    (DataFormat::Text(_), _)
311                    | (DataFormat::Number(_), _)
312                    | (DataFormat::Switch(_), _)
313                    | (DataFormat::Choice(_), _)
314                    | (DataFormat::File(_), _)
315                    | (DataFormat::Folder(_), _)
316                    | (DataFormat::Color(_), _)
317                    | (DataFormat::LowerBound(_), _)
318                    | (DataFormat::UpperBound(_), _) => {
319                        return Err(format!(
320                            "data field {id} appears multiple times with different definitions"
321                        ));
322                    }
323                }
324            }
325        }
326
327        // Check for duplicate action IDs across all categories
328        let mut action_ids = HashSet::new();
329        for action in self.categories.iter().flatten().flat_map(|c| &c.actions) {
330            if !action_ids.insert(&action.id) {
331                return Err(format!(
332                    "duplicate action ID '{}' found - action IDs must be unique across all categories",
333                    action.id
334                ));
335            }
336        }
337
338        // Validate plugin ID format
339        let id = self.id.as_ref().expect("id is required");
340        if id.trim().is_empty() {
341            return Err("plugin ID cannot be empty".to_string());
342        }
343
344        // Check for valid characters (alphanumeric, dots, hyphens, underscores)
345        if !id
346            .chars()
347            .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
348        {
349            return Err(format!(
350                "invalid plugin ID '{}' - plugin IDs must contain only alphanumeric characters, dots, hyphens, and underscores",
351                id
352            ));
353        }
354
355        // Validate API version compatibility with used features
356        let api_version = self.api.as_ref().expect("api is required");
357        let api_version_num = *api_version as u16;
358
359        // Check LowerBound/UpperBound data types require API v10+ (V4_3)
360        for action in self.categories.iter().flatten().flat_map(|c| &c.actions) {
361            for data in &action.data {
362                match &data.format {
363                    crate::DataFormat::LowerBound(_) => {
364                        if api_version_num < 10 {
365                            return Err(format!(
366                                "feature 'LowerBound' requires API version 10 or higher, but plugin specifies API version {}",
367                                api_version_num
368                            ));
369                        }
370                    }
371                    crate::DataFormat::UpperBound(_) => {
372                        if api_version_num < 10 {
373                            return Err(format!(
374                                "feature 'UpperBound' requires API version 10 or higher, but plugin specifies API version {}",
375                                api_version_num
376                            ));
377                        }
378                    }
379                    _ => {}
380                }
381            }
382        }
383
384        // Check connectors for LowerBound/UpperBound usage
385        for connector in self.categories.iter().flatten().flat_map(|c| &c.connectors) {
386            for data in &connector.data {
387                match &data.format {
388                    crate::DataFormat::LowerBound(_) => {
389                        if api_version_num < 10 {
390                            return Err(format!(
391                                "feature 'LowerBound' requires API version 10 or higher, but plugin specifies API version {}",
392                                api_version_num
393                            ));
394                        }
395                    }
396                    crate::DataFormat::UpperBound(_) => {
397                        if api_version_num < 10 {
398                            return Err(format!(
399                                "feature 'UpperBound' requires API version 10 or higher, but plugin specifies API version {}",
400                                api_version_num
401                            ));
402                        }
403                    }
404                    _ => {}
405                }
406            }
407        }
408
409        // Validate connector format strings reference existing data fields
410        for connector in self.categories.iter().flatten().flat_map(|c| &c.connectors) {
411            // Extract data field references from format string (e.g., {$field_id$})
412            let format_refs = connector
413                .format
414                .split("{$")
415                .skip(1) // Skip text before first {$
416                .filter_map(|s| s.split("$}").next());
417
418            // Check that each referenced field exists in the connector's data
419            let connector_data_ids: std::collections::HashSet<_> =
420                connector.data.iter().map(|d| d.id.as_str()).collect();
421            for field_ref in format_refs {
422                if !connector_data_ids.contains(field_ref) {
423                    return Err(format!(
424                        "connector {} references data field {} in format string, but no such data field is defined",
425                        connector.id, field_ref
426                    ));
427                }
428            }
429        }
430
431        Ok(())
432    }
433}
434
435/// A category in your plugin will be an action category in Touch Portal.
436///
437/// Users can open that category and select actions, events and/or connectors from that to use in
438/// their buttons or sliders. A plugin can include as many categories as you want, but best
439/// practise is to use them as actual categories. Group actions for the same software integration
440/// in one category. This will allow the users have the best experience. Also keep in mind that if
441/// the users do not like the additions of your setup, they can just remove the plugins.
442#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
443#[serde(rename_all = "camelCase")]
444pub struct Category {
445    /// This is the id of the category.
446    #[builder(setter(into))]
447    id: String,
448
449    /// This is the name of the category.
450    #[builder(setter(into))]
451    name: String,
452
453    /// This is the absolute path to an icon for the category.
454    ///
455    /// You should place this in your plugin folder and reference it. If you use
456    /// `%TP_PLUGIN_FOLDER%` in the text here, it will be replaced with the path to the folder
457    /// containing all plug-ins.
458    ///
459    /// Images must be 32bit PNG files of size 24x24 that are, and should be white icons with a
460    /// transparent background.
461    ///
462    /// Although colored icons are possible, they will be removed in the near future.
463    #[builder(setter(into, strip_option), default)]
464    #[serde(skip_serializing_if = "Option::is_none")]
465    imagepath: Option<String>,
466
467    /// This is the collection that holds all the actions.
468    #[builder(setter(each(name = "action")), default)]
469    actions: Vec<Action>,
470
471    /// This is the collection that holds all the events.
472    #[builder(setter(each(name = "event")), default)]
473    events: Vec<Event>,
474
475    /// This is the collection that holds all the connectors.
476    #[builder(setter(each(name = "connector")), default)]
477    connectors: Vec<Connector>,
478
479    /// This is the collection that holds all the states.
480    #[builder(setter(each(name = "state")), default)]
481    states: Vec<State>,
482
483    /// This is the collection of sub categories that you can define.
484    ///
485    /// You can assign actions, events and connectors to these categories. This will allow you to
486    /// add subcategories for you plugin that will be shown in the action selection control.
487    #[serde(skip_serializing_if = "Vec::is_empty")]
488    #[builder(setter(each(name = "sub_category")), default)]
489    sub_categories: Vec<SubCategory>,
490}
491
492impl Category {
493    pub fn builder() -> CategoryBuilder {
494        CategoryBuilder::default()
495    }
496}
497
498/// Plugin Categories can have sub categories which will be used to add structure to your list of
499/// actions, events and connectors.
500#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
501#[serde(rename_all = "camelCase")]
502pub struct SubCategory {
503    /// This is the id of the sub category.
504    ///
505    /// It is used to identify the sub category within Touch Portal. This id needs to be unique
506    /// across plugins. This means that if you give it the id "1" there is a big chance that it
507    /// will be a duplicate. Touch Portal may reject it or when the other state is updated, yours
508    /// will be as well with wrong data. Best practice is to create a unique prefix for all your
509    /// sub categories like in our case; `tp_subcat_groceries.fruit`.
510    #[builder(setter(into))]
511    id: String,
512
513    /// The name of the sub category.
514    ///
515    /// This name will be used to display the category in the actions lists. It can may be used in
516    /// different systems as well.
517    #[builder(setter(into))]
518    name: String,
519
520    /// This is the absolute path to an icon for the category.
521    ///
522    /// You should place this in your plugin folder and reference it. If you use
523    /// `%TP_PLUGIN_FOLDER%` in the text here, it will be replaced with the path to the folder
524    /// containing all plug-ins.
525    ///
526    /// Images must be 32bit PNG files of size 24x24 that are, and should be white icons with a
527    /// transparent background.
528    ///
529    /// Although colored icons are possible, they will be removed in the near future.
530    #[builder(setter(into, strip_option), default)]
531    #[serde(skip_serializing_if = "Option::is_none")]
532    imagepath: Option<String>,
533}
534
535impl SubCategory {
536    pub fn builder() -> SubCategoryBuilder {
537        SubCategoryBuilder::default()
538    }
539}
540
541#[derive(Debug, Clone, Builder, Deserialize, Serialize)]
542#[serde(rename_all = "camelCase")]
543pub struct PluginConfiguration {
544    /// When users use your actions and events they will be rendered in their own flows.
545    ///
546    /// This attribute tells Touch Portal which dark color to use in those actions and events. When
547    /// this is not specified the default plug-in colors will be used in Touch Portal. Preferably
548    /// use the color schemes of the software or product you are making a plug-in for to increase
549    /// recognizability.
550    ///
551    /// Note: these color will be ignored in some of the themes within Touch Portal. There is no
552    /// way to override this behaviour.
553    #[serde(skip_serializing_if = "Option::is_none")]
554    #[builder(setter(into, strip_option), default)]
555    color_dark: Option<HexColor>,
556
557    /// When users use your actions and events they will be rendered in their own flows.
558    ///
559    /// This attribute tells Touch Portal which light color to use in those actions and events. When
560    /// this is not specified the default plug-in colors will be used in Touch Portal. Preferably
561    /// use the color schemes of the software or product you are making a plug-in for to increase
562    /// recognizability.
563    ///
564    /// Note: these color will be ignored in some of the themes within Touch Portal. There is no
565    /// way to override this behaviour.
566    #[serde(skip_serializing_if = "Option::is_none")]
567    #[builder(setter(into, strip_option), default)]
568    color_light: Option<HexColor>,
569
570    /// The specific category within Touch Portal your plug-in falls into.
571    #[serde(skip_serializing_if = "Option::is_none")]
572    #[builder(setter(into, strip_option), default)]
573    parent_category: Option<PluginCategory>,
574}
575
576impl PluginConfiguration {
577    pub fn builder() -> PluginConfigurationBuilder {
578        PluginConfigurationBuilder::default()
579    }
580}
581
582/// You can add your plug-in in specific categories within Touch Portal.
583///
584/// These main categories are used within the category and action control which the user uses to
585/// add an action to a flow of actions.
586#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default)]
587#[serde(rename_all = "lowercase")]
588#[non_exhaustive]
589pub enum PluginCategory {
590    /// For all audio, music and media related plug-ins.
591    Audio,
592
593    /// For all streaming related plug-ins.
594    Streaming,
595
596    /// For all Content Creation related plug-ins.
597    Content,
598
599    /// For all Home Automation related plug-ins.
600    HomeAutomation,
601
602    /// For all social media related plug-ins.
603    Social,
604
605    /// For all game related plug-ins.
606    Games,
607
608    /// This is the default category a plugin falls into even when this attribute of the
609    /// configuration has not been specified.
610    ///
611    /// All plug-ins not fitting in one of the categories above should be placed in this category.
612    #[default]
613    Misc,
614
615    /// For all conferencing calls related plug-ins.
616    ///
617    /// Only available in API version 10 and above.
618    Conferencing,
619
620    /// For all office type of application and services related plug-ins.
621    ///
622    /// Only available in API version 10 and above.
623    Office,
624
625    /// For all System related plug-ins.
626    ///
627    /// Only available in API version 10 and above.
628    System,
629
630    /// For all tools not fitting in other categories.
631    ///
632    /// Only available in API version 10 and above.
633    Tools,
634
635    /// For all communication and transport protocol related plug-ins.
636    ///
637    /// Only available in API version 10 and above.
638    Transport,
639
640    /// For all input device and controller related plug-ins.
641    ///
642    /// Only available in API version 10 and above.
643    Input,
644}
645
646mod data;
647pub use data::*;
648
649mod actions;
650pub use actions::*;
651
652mod events;
653pub use events::*;
654
655mod connectors;
656pub use connectors::*;
657
658mod states;
659pub use states::*;
660
661mod settings;
662pub use settings::*;
663use std::collections::{HashMap, HashSet};
664
665#[test]
666fn serialize_tutorial_sdk_example() {
667    assert_eq!(
668        serde_json::to_value(
669            PluginDescription::builder()
670                .api(ApiVersion::V4_3)
671                .version(1)
672                .name("Tutorial SDK Plugin")
673                .id("tp_tut_001")
674                .configuration(
675                    PluginConfiguration::builder()
676                        .color_dark(HexColor::from_u24(0xFF0000))
677                        .color_light(HexColor::from_u24(0x00FF00))
678                        .parent_category(PluginCategory::Misc)
679                        .build()
680                        .unwrap()
681                )
682                .plugin_start_cmd("executable.exe -param")
683                .build()
684                .unwrap()
685        )
686        .unwrap(),
687        serde_json::json! {{
688          "api":10,
689          "version":1,
690          "name":"Tutorial SDK Plugin",
691          "id":"tp_tut_001",
692          "configuration" : {
693            "colorDark" : "#FF0000",
694            "colorLight" : "#00FF00",
695            "parentCategory" : "misc"
696          },
697          "plugin_start_cmd":"executable.exe -param",
698          "categories": [ ],
699          "settings": [ ],
700        }}
701    );
702}
703
704#[test]
705fn serialize_tutorial_sdk_category_example() {
706    assert_eq!(
707        serde_json::to_value(
708            Category::builder()
709                .id("tp_tut_001_cat_01")
710                .name("Tools")
711                .imagepath("%TP_PLUGIN_FOLDER%ExamplePlugin/images/tools.png")
712                .build()
713                .unwrap()
714        )
715        .unwrap(),
716        serde_json::json! {{
717          "id":"tp_tut_001_cat_01",
718          "name":"Tools",
719          "imagepath":"%TP_PLUGIN_FOLDER%ExamplePlugin/images/tools.png",
720          "actions": [ ],
721          "events": [ ],
722          "connectors": [ ],
723          "states": [ ]
724        } }
725    );
726}
727
728#[test]
729fn serialize_tutorial_sdk_plugin_with_category_example() {
730    assert_eq!(
731        serde_json::to_value(
732            PluginDescription::builder()
733                .api(ApiVersion::V4_3)
734                .version(1)
735                .name("Tutorial SDK Plugin")
736                .id("tp_tut_001")
737                .configuration(
738                    PluginConfiguration::builder()
739                        .color_dark(HexColor::from_u24(0xFF0000))
740                        .color_light(HexColor::from_u24(0x00FF00))
741                        .parent_category(PluginCategory::Misc)
742                        .build()
743                        .unwrap()
744                )
745                .plugin_start_cmd("executable.exe -param")
746                .category(
747                    Category::builder()
748                        .id("tp_tut_001_cat_01")
749                        .name("Tools")
750                        .imagepath("%TP_PLUGIN_FOLDER%Tutorial SDK Plugin/images/tools.png")
751                        .build()
752                        .unwrap()
753                )
754                .build()
755                .unwrap()
756        )
757        .unwrap(),
758        serde_json::json! {{
759          "api":10,
760          "version":1,
761          "name":"Tutorial SDK Plugin",
762          "id":"tp_tut_001",
763          "configuration" : {
764            "colorDark" : "#FF0000",
765            "colorLight" : "#00FF00",
766            "parentCategory" : "misc"
767          },
768          "plugin_start_cmd":"executable.exe -param",
769          "categories": [
770            {
771              "id":"tp_tut_001_cat_01",
772              "name":"Tools",
773              "imagepath":"%TP_PLUGIN_FOLDER%Tutorial SDK Plugin/images/tools.png",
774              "actions": [ ],
775              "events": [ ],
776              "connectors": [ ],
777              "states": [ ],
778            }
779          ],
780          "settings": [ ],
781        } }
782    );
783}