Implement assigning actions to slots and persisting profiles

This commit is contained in:
ninjadev64 committed 2023-12-06 19:43:23 +00:00
1 parent c77d00c545
commit c30c00c34e
21 files changed
+452 -54

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ tauri-build = { version = "1.5.0", features = [] }
[dependencies]
# Large, fundamental libraries
tauri = { version = "1.5.2", features = [ "system-tray", "api-all" ] }
tauri = { version = "1.5.2", features = [ "system-tray", "api-all", "devtools" ] }
serde = { version = "1.0", features = [ "derive" ] }
serde_json = "1.0"
serialport = "4.2.2"
+3 -1
View File
@@ -1,5 +1,7 @@
mod prontokey;
use std::collections::HashMap;
use lazy_static::lazy_static;
use std::sync::Mutex;
@@ -48,7 +50,7 @@ impl DeviceInfo {
}
lazy_static! {
pub static ref DEVICES: Mutex<Vec<DeviceInfo>> = Mutex::new(vec![]);
pub static ref DEVICES: Mutex<HashMap<String, DeviceInfo>> = Mutex::new(HashMap::new());
}
/// Attempt to initialise all connected devices.
+1 -1
View File
@@ -76,7 +76,7 @@ impl ProntoKeyDevice {
if let Value::String(address) = &j["address"] {
device = Some(ProntoKeyDevice { address: address.clone() });
if let Some(device) = &device {
super::DEVICES.lock().unwrap().push(super::DeviceInfo::new(device));
super::DEVICES.lock().unwrap().insert(device.id(), super::DeviceInfo::new(device));
}
}
continue;
+94
View File
@@ -0,0 +1,94 @@
use crate::store::profiles::{PROFILE_STORES, DEVICE_STORES, get_device_profiles};
use crate::devices::DEVICES;
use crate::shared::{Action, ActionContext, ActionInstance, CATEGORIES};
use std::collections::HashMap;
#[derive(serde::Serialize, serde::Deserialize)]
struct Error {
pub description: String
}
fn serialise_mutex_hashmap<T>(map: &std::sync::Mutex<HashMap<String, T>>) -> String where T: serde::Serialize {
// Here, we "duplicate" the HashMap so it isn't captured in a MutexGuard, allowing it to be serialised
let mut hash_map: HashMap<String, &T> = HashMap::new();
let locked = map.lock().unwrap();
for key in locked.keys() {
hash_map.insert(key.to_owned(), locked.get(key).unwrap());
}
serde_json::to_string(&hash_map).unwrap()
}
// Strings are returned from many of these commands as their return values often reference static Mutexes.
#[tauri::command]
pub fn get_devices() -> String {
serialise_mutex_hashmap(&*DEVICES)
}
#[tauri::command]
pub fn get_categories() -> String {
serialise_mutex_hashmap(&*CATEGORIES)
}
#[tauri::command]
pub fn get_profiles(app: tauri::AppHandle, device: &str) -> String {
match get_device_profiles(device, &app) {
Ok(profiles) => serde_json::to_string(&profiles).unwrap(),
Err(error) => serde_json::to_string(&Error { description: error.to_string() }).unwrap()
}
}
#[tauri::command]
pub fn get_selected_profile(app: tauri::AppHandle, device: &str) -> String {
match DEVICE_STORES.lock().unwrap().get_device_store(device, &app) {
Ok(store) => {
match PROFILE_STORES.lock().unwrap().get_profile_store(
DEVICES.lock().unwrap().get(device).unwrap(),
&store.value.selected_profile,
&app
) {
Ok(store) => serde_json::to_string(&store.value).unwrap(),
Err(error) => serde_json::to_string(&Error { description: error.to_string() }).unwrap()
}
},
Err(error) => serde_json::to_string(&Error { description: error.to_string() }).unwrap()
}
}
#[tauri::command]
pub fn set_selected_profile(app: tauri::AppHandle, device: &str, profile: &str) {
let mut device_stores = DEVICE_STORES.lock().unwrap();
let store = device_stores.get_device_store(device, &app).unwrap();
store.value.selected_profile = profile.to_owned();
let _ = store.save();
}
#[tauri::command]
pub fn create_instance(app: tauri::AppHandle, action: Action, context: ActionContext) -> String {
let instance = ActionInstance {
action: action.clone(),
context: context.clone(),
states: action.states.clone(),
current_state: 0,
settings: serde_json::Value::Null
};
let mut profile_stores = PROFILE_STORES.lock().unwrap();
let store = match profile_stores.get_profile_store(
DEVICES.lock().unwrap().get(&context.device).unwrap(),
&context.profile,
&app
) {
Ok(store) => store,
Err(error) => return serde_json::to_string(&Error { description: error.to_string() }).unwrap()
};
store.value.keys[context.position as usize] = Some(instance);
if let Err(error) = store.save() {
return serde_json::to_string(&Error { description: error.to_string() }).unwrap();
}
serde_json::to_string(&store.value.keys[context.position as usize]).unwrap()
}
+1
View File
@@ -1,4 +1,5 @@
mod inbound;
pub mod frontend;
use std::collections::HashMap;
+12 -20
View File
@@ -1,39 +1,31 @@
// Prevents additional console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::collections::HashMap;
mod shared;
mod store;
mod events;
mod plugins;
mod devices;
#[tauri::command]
fn get_devices() -> Vec<devices::DeviceInfo> {
devices::DEVICES.lock().unwrap().to_vec()
}
#[tauri::command]
fn get_categories() -> String {
// Here, we "duplicate" the HashMap so it isn't captured in a MutexGuard, allowing it to be serialised
let mut hash_map: HashMap<String, &Vec<shared::Action>> = HashMap::new();
let categories = shared::CATEGORIES.lock().unwrap();
for key in categories.keys() {
hash_map.insert(key.to_string(), categories.get(key).unwrap());
}
serde_json::to_string(&hash_map).unwrap()
}
use events::frontend;
#[tokio::main]
async fn main() {
let app = match
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![get_devices, get_categories])
let app = match tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
frontend::get_devices,
frontend::get_categories,
frontend::create_instance,
frontend::get_profiles,
frontend::get_selected_profile,
frontend::set_selected_profile
])
.build(tauri::generate_context!())
{
Ok(app) => app,
Err(error) => panic!("Failed to create Tauri application: {}", error)
};
devices::initialise_devices();
plugins::initialise_plugins(app.handle());
+7 -7
View File
@@ -28,11 +28,11 @@ fn initialise_plugin(path: path::PathBuf, app: &AppHandle, categories: &mut Hash
.with_context(|| { format!("Failed to parse manifest of plugin at {}", manifest_path.display()) })?;
for action in &mut manifest.actions {
action.plugin = plugin_uuid.to_string();
action.plugin = plugin_uuid.to_owned();
let mut action_icon_path = path.clone();
action_icon_path.push(action.icon.clone());
action.icon = convert_icon(action_icon_path.to_str().unwrap().to_string());
action.icon = convert_icon(action_icon_path.to_str().unwrap().to_owned());
for state in &mut action.states {
if state.image == "actionDefaultImage" {
@@ -40,7 +40,7 @@ fn initialise_plugin(path: path::PathBuf, app: &AppHandle, categories: &mut Hash
} else {
let mut state_icon = path.clone();
state_icon.push(state.image.clone());
state.image = convert_icon(state_icon.to_str().unwrap().to_string());
state.image = convert_icon(state_icon.to_str().unwrap().to_owned());
}
}
}
@@ -97,7 +97,7 @@ fn initialise_plugin(path: path::PathBuf, app: &AppHandle, categories: &mut Hash
}
let mut devices: Vec<info_param::DeviceInfo> = vec![];
for device in crate::devices::DEVICES.lock().unwrap().iter() {
for device in crate::devices::DEVICES.lock().unwrap().values() {
devices.push(info_param::DeviceInfo::new(device));
}
@@ -110,7 +110,7 @@ fn initialise_plugin(path: path::PathBuf, app: &AppHandle, categories: &mut Hash
version: env!("CARGO_PKG_VERSION").to_owned()
},
plugin: info_param::PluginInfo {
uuid: plugin_uuid.to_string(),
uuid: plugin_uuid.to_owned(),
version: manifest.version
},
devicePixelRatio: 0,
@@ -155,7 +155,7 @@ fn initialise_plugin(path: path::PathBuf, app: &AppHandle, categories: &mut Hash
.args([
code_path,
String::from("-port"), 57116.to_string(),
String::from("-pluginUUID"), plugin_uuid.to_string(),
String::from("-pluginUUID"), plugin_uuid.to_owned(),
String::from("-registerEvent"), String::from("register"),
String::from("-info"), serde_json::to_string(&info).unwrap()
])
@@ -169,7 +169,7 @@ fn initialise_plugin(path: path::PathBuf, app: &AppHandle, categories: &mut Hash
.current_dir(&path)
.args([
String::from("-port"), 57116.to_string(),
String::from("-pluginUUID"), plugin_uuid.to_string(),
String::from("-pluginUUID"), plugin_uuid.to_owned(),
String::from("-registerEvent"), String::from("register"),
String::from("-info"), serde_json::to_string(&info).unwrap()
])
+12 -4
View File
@@ -20,7 +20,7 @@ pub fn convert_icon(path: String) -> String {
/// A state of an action.
#[serde_inline_default]
#[derive(Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct ActionState {
#[serde(alias = "Image")]
pub image: String,
@@ -60,7 +60,7 @@ pub struct ActionState {
/// An action, deserialised from the plugin manifest.
#[serde_inline_default]
#[derive(Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct Action {
#[serde(alias = "Name")]
pub name: String,
@@ -94,7 +94,7 @@ pub struct Action {
}
/// Information about the slot an instance is located in.
#[derive(Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct ActionContext {
pub device: String,
pub profile: String,
@@ -116,7 +116,15 @@ pub struct ActionInstance {
pub context: ActionContext,
pub states: Vec<ActionState>,
pub current_state: u16,
pub settings: String
pub settings: serde_json::Value
}
#[derive(Serialize, Deserialize)]
pub struct Profile {
pub device: String,
pub id: String,
pub keys: Vec<Option<ActionInstance>>,
pub sliders: Vec<Option<ActionInstance>>
}
lazy_static! {
+51
View File
@@ -0,0 +1,51 @@
pub mod profiles;
use std::fs;
use std::path::PathBuf;
use serde::{Serialize, Deserialize};
use anyhow::Context;
/// Allows for easy persistence of values using JSON files.
pub struct Store<T> where T: Serialize + for<'a> Deserialize<'a> {
pub id: String,
pub value: T,
path: PathBuf
}
impl<T> Store<T> where T: Serialize + for<'a> Deserialize<'a> {
/// Create a new Store given an ID and storage directory.
pub fn new(id: &str, config_dir: PathBuf, default: T) -> Result<Self, anyhow::Error> {
let mut path = config_dir.clone();
path.push(format!("{}.json", id));
if path.exists() {
let file_contents = fs::read(&path)
.with_context(|| { format!("Failed to read contents of file at {}", &path.display()) })?;
let existing_value: T = serde_json::from_slice(&file_contents)
.with_context(|| { format!("Failed to parse config file at {}", &path.display()) })?;
Ok(Self {
path,
id: String::from(id),
value: existing_value
})
} else {
Ok(Self {
path,
id: String::from(id),
value: default
})
}
}
/// Save the relevant Store as a file.
pub fn save(&self) -> anyhow::Result<(), anyhow::Error> {
fs::create_dir_all(self.path.parent().unwrap())
.with_context(|| { format!("Failed to create directories at {}", self.path.display()) })?;
fs::write(&self.path, serde_json::to_string_pretty(&self.value).unwrap())
.with_context(|| { format!("Failed to write to file at {}", self.path.display()) })?;
Ok(())
}
}
+103
View File
@@ -0,0 +1,103 @@
use super::Store;
use crate::shared::Profile;
use std::fs;
use std::sync::Mutex;
use std::collections::HashMap;
use std::iter::repeat_with;
use serde::{Serialize, Deserialize};
use anyhow::Context;
use lazy_static::lazy_static;
pub struct ProfileStores {
stores: HashMap<String, Store<Profile>>
}
impl ProfileStores {
pub fn get_profile_store(&mut self, device: &crate::devices::DeviceInfo, id: &str, app: &tauri::AppHandle) -> Result<&mut Store<Profile>, anyhow::Error> {
let path = format!("profiles/{}/{}", device.id, id);
if self.stores.contains_key(&path) {
Ok(self.stores.get_mut(&path).unwrap())
} else {
let default = Profile {
device: device.id.clone(),
id: id.to_owned(),
keys: repeat_with(|| None).take((device.rows * device.columns).into()).collect(),
sliders: repeat_with(|| None).take(device.sliders.into()).collect()
};
let store =
Store::new(&path, app.path_resolver().app_config_dir().unwrap(), default)
.with_context(|| { format!("Failed to create store for profile {}", path) })?;
store.save().with_context(|| { format!("Failed to save store for profile {}", path) })?;
self.stores.insert(path.clone(), store);
Ok(self.stores.get_mut(&path).unwrap())
}
}
}
#[derive(Serialize, Deserialize)]
pub struct DeviceConfig {
pub selected_profile: String
}
pub struct DeviceStores {
stores: HashMap<String, Store<DeviceConfig>>
}
impl DeviceStores {
pub fn get_device_store(&mut self, device: &str, app: &tauri::AppHandle) -> Result<&mut Store<DeviceConfig>, anyhow::Error> {
if self.stores.contains_key(device) {
Ok(self.stores.get_mut(device).unwrap())
} else {
let default = DeviceConfig {
selected_profile: String::from("Default")
};
let store =
Store::new(&format!("profiles/{}", device), app.path_resolver().app_config_dir().unwrap(), default)
.with_context(|| { format!("Failed to create config store for device {}", device) })?;
store.save().with_context(|| { format!("Failed to save config store for device {}", device) })?;
self.stores.insert(device.to_owned(), store);
Ok(self.stores.get_mut(device).unwrap())
}
}
}
pub fn get_device_profiles(device: &str, app: &tauri::AppHandle) -> Result<Vec<String>, anyhow::Error> {
let mut profiles: Vec<String> = vec![];
let device_path = app.path_resolver().app_config_dir().unwrap().join(format!("profiles/{}", device));
fs::create_dir_all(&device_path).with_context(|| { format!("Failed to create directories for device {}", device) })?;
let entries =
fs::read_dir(device_path)
.with_context(|| { format!("Failed to read directory for device {}", device) })?;
for entry in entries.flatten() {
if entry.metadata().unwrap().is_file() && entry.file_name() != "config.json" {
profiles.push(entry.file_name().to_string_lossy()[..entry.file_name().len() - 5].to_owned());
}
}
if profiles.is_empty() {
profiles.push("Default".to_owned());
}
Ok(profiles)
}
lazy_static! {
/// A singleton object to contain all active Store instances that hold a profile.
pub static ref PROFILE_STORES: Mutex<ProfileStores> = Mutex::new(ProfileStores { stores: HashMap::new() });
/// A singleton object to manage Store instances for device configurations.
pub static ref DEVICE_STORES: Mutex<DeviceStores> = Mutex::new(DeviceStores { stores: HashMap::new() });
}
+1 -1
View File
@@ -38,7 +38,7 @@
]
},
"security": {
"csp": "default-src 'self'; img-src 'self' data: https://appstore.elgato.com/; connect-src https://appstore.elgato.com/;"
"csp": "default-src 'self'; img-src 'self' data: asset: https://appstore.elgato.com/; connect-src https://appstore.elgato.com/;"
},
"allowlist": {
"all": true,
+6 -8
View File
@@ -1,22 +1,20 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
import { invoke } from "@tauri-apps/api";
let devices: DeviceInfo[] = [];
let devices: { [id: string]: DeviceInfo } = {};
async function refreshDevices() {
devices = await invoke("get_devices");
devices = JSON.parse(await invoke("get_devices"));
}
let value: string;
export let device;
function change() {
if (value == "refresh_devices") {
refreshDevices();
value = "placeholder";
} else {
dispatch("select", devices[parseInt(value)]);
device = devices[value];
}
}
</script>
@@ -24,8 +22,8 @@
<select bind:value on:change={change} class="w-full">
<option value="placeholder" disabled selected> Choose a device... </option>
{#each devices as device, index}
<option value={index}> {device.name} </option>
{#each Object.entries(devices) as [ id, device ]}
<option value={id}> {device.name} </option>
{/each}
<option value="refresh_devices"> Refresh devices </option>
+24 -5
View File
@@ -3,18 +3,37 @@
import Slider from "./Slider.svelte";
export let device: DeviceInfo;
export let profile: Profile;
</script>
<div class="flex flex-row">
{#each { length: device.sliders } as _}
<Slider />
{#each { length: device.sliders } as _, i}
<Slider
context={{
device: device.id,
profile: profile.id,
controller: "Encoder",
position: i,
index: 0
}}
instance={profile.sliders[i]}
/>
{/each}
<div class="flex flex-col">
{#each { length: device.rows } as _}
{#each { length: device.rows } as _, r}
<div class="flex flex-row">
{#each { length: device.columns } as _}
<Key />
{#each { length: device.columns } as _, c}
<Key
context={{
device: device.id,
profile: profile.id,
controller: "Keypad",
position: (r * device.columns) + c,
index: 0
}}
instance={profile.keys[(r * device.columns) + c]}
/>
{/each}
</div>
{/each}
+33 -1
View File
@@ -1 +1,33 @@
<div class="m-2 w-32 h-32 border-2 rounded-md"></div>
<script lang="ts">
import { invoke } from "@tauri-apps/api";
import { convertFileSrc } from "@tauri-apps/api/tauri";
export let context: ActionContext;
export let instance: ActionInstance | null;
function handleDragOver(event: DragEvent) {
event.preventDefault();
return true;
}
async function handleDrop({ dataTransfer }: DragEvent) {
let action = dataTransfer?.getData("action");
if (!action) return;
instance = JSON.parse(await invoke("create_instance", { context, action: JSON.parse(action) }));
}
</script>
<div
class="m-2 w-32 h-32 border-2 rounded-md"
on:dragover={handleDragOver}
on:drop={handleDrop}
role="cell" tabindex="-1"
>
{#if instance}
<img
src={convertFileSrc(instance.states[instance.current_state].image)}
class="p-2 w-full rounded-xl"
alt={instance.action.tooltip}
/>
{/if}
</div>
+11 -1
View File
@@ -1,9 +1,19 @@
<script lang="ts">
import { convertFileSrc } from "@tauri-apps/api/tauri";
export let action: Action;
function handleDragStart(event: DragEvent) {
event.dataTransfer?.setData("action", JSON.stringify(action));
}
</script>
<div class="flex flex-row items-center mt-2 mb-2 space-x-2">
<img src={convertFileSrc(action.icon)} alt={action.tooltip} class="w-12 h-12 rounded-sm" />
<img
src={convertFileSrc(action.icon)}
alt={action.tooltip}
class="w-12 h-12 rounded-sm"
draggable
on:dragstart={handleDragStart}
/>
<span> {action.name} </span>
</div>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api";
let profiles: string[] = [];
async function getProfiles(device: DeviceInfo) {
profiles = JSON.parse(await invoke("get_profiles", { device: device.id }));
value = profiles[0];
}
export let profile: Profile;
async function setProfile(id: string) {
if (!device || !id) return;
await invoke("set_selected_profile", { device: device.id, profile: id });
profile = JSON.parse(await invoke("get_selected_profile", { device: device.id }));
}
let value: string;
$: setProfile(value);
export let device: DeviceInfo;
$: getProfiles(device);
</script>
<select bind:value class="mt-1 w-full">
{#each profiles as profile}
<option value={profile}> {profile} </option>
{/each}
</select>
+33 -1
View File
@@ -1 +1,33 @@
<div class="m-2 w-20 h-144 border-2 rounded-md"></div>
<script lang="ts">
import { invoke } from "@tauri-apps/api";
import { convertFileSrc } from "@tauri-apps/api/tauri";
export let context: ActionContext;
export let instance: ActionInstance | null;
function handleDragOver(event: DragEvent) {
event.preventDefault();
return true;
}
async function handleDrop({ dataTransfer }: DragEvent) {
let action = dataTransfer?.getData("action");
if (!action) return;
instance = JSON.parse(await invoke("create_instance", { context, action: JSON.parse(action) }));
}
</script>
<div
class="flex items-center m-2 w-20 h-144 border-2 rounded-md"
on:dragover={handleDragOver}
on:drop={handleDrop}
role="cell" tabindex="-1"
>
{#if instance}
<img
src={convertFileSrc(instance.states[instance.current_state].image)}
class="p-2 w-full rounded-xl"
alt={instance.action.tooltip}
/>
{/if}
</div>
+7
View File
@@ -0,0 +1,7 @@
type ActionContext = {
device: string,
profile: string,
controller: string,
position: number,
index: number
};
+7
View File
@@ -0,0 +1,7 @@
type ActionInstance = {
action: Action,
context: ActionContext,
states: ActionState[],
current_state: number,
settings: any
};
+6
View File
@@ -0,0 +1,6 @@
type Profile = {
device: string,
id: string,
keys: (ActionInstance | null)[],
sliders: (ActionInstance | null)[]
};
+11 -3
View File
@@ -2,17 +2,25 @@
import ActionList from "../components/ActionList.svelte";
import DeviceSelector from "../components/DeviceSelector.svelte";
import DeviceView from "../components/DeviceView.svelte";
import ProfileSelector from "../components/ProfileSelector.svelte";
let selectedDevice: DeviceInfo;
$: _selectedDevice = selectedDevice;
let selectedProfile: Profile;
$: _selectedProfile = selectedProfile;
</script>
<div class="grow">
{#if selectedDevice}
<DeviceView device={selectedDevice} />
{#if selectedDevice && selectedProfile}
<DeviceView bind:device={_selectedDevice} bind:profile={_selectedProfile} />
{/if}
</div>
<div class="flex flex-col p-2 grow max-w-[18rem] h-full border-l">
<DeviceSelector on:select={({ detail }) => selectedDevice = detail} />
<DeviceSelector bind:device={selectedDevice} />
{#if selectedDevice}
<ProfileSelector bind:device={_selectedDevice} bind:profile={selectedProfile} />
{/if}
<ActionList />
</div>