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}