From 568dcd122579a47391ef52f33a524d73a007a00d Mon Sep 17 00:00:00 2001 From: Alex Hansen Date: Sun, 12 Apr 2026 21:16:09 -0400 Subject: [PATCH 01/25] feat(plugins): let plugins declare button actions via buttonActions API Remove hardcoded per-integration button action logic from targets.js and replace it with a plugin-driven resolution chain: per-target buttonActions > integration-level buttonActions > defaults. Plugins now declare their own actions in registerIntegration() and per-target in getTargetOptions(), so new integrations no longer require changes to the core app code. Co-Authored-By: Claude Opus 4.6 --- src-tauri/builtin_plugins/hue/plugin.mjs | 3 ++ src-tauri/builtin_plugins/obs/plugin.mjs | 8 +++ src-tauri/builtin_plugins/wavelink/plugin.mjs | 3 ++ src/features/targets/targets.js | 50 ++++++++++++++----- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src-tauri/builtin_plugins/hue/plugin.mjs b/src-tauri/builtin_plugins/hue/plugin.mjs index eda3701..20b92ec 100644 --- a/src-tauri/builtin_plugins/hue/plugin.mjs +++ b/src-tauri/builtin_plugins/hue/plugin.mjs @@ -747,6 +747,9 @@ export async function activate(ctx) { id: "hue", name: "Philips Hue", icon_data: iconDataUrl || null, + buttonActions: [ + { label: "Toggle", value: "ToggleMute" }, + ], describeTarget: (target) => { const t = normalizeIntegrationTarget(target); if (!t) { diff --git a/src-tauri/builtin_plugins/obs/plugin.mjs b/src-tauri/builtin_plugins/obs/plugin.mjs index de993ef..02735fd 100644 --- a/src-tauri/builtin_plugins/obs/plugin.mjs +++ b/src-tauri/builtin_plugins/obs/plugin.mjs @@ -552,6 +552,10 @@ export async function activate(ctx) { id: "obs", name: "OBS Studio", icon_data: iconDataUrl || null, + buttonActions: [ + { label: "Trigger", value: "Volume" }, + { label: "Toggle Mute", value: "ToggleMute" }, + ], describeTarget: (target) => { const t = target?.Integration || target?.integration; const data = t?.data || {}; @@ -606,6 +610,7 @@ export async function activate(ctx) { label: `Switch to ${sceneName}`, icon_data: iconDataUrl || null, target: makeSceneTarget(sceneName), + buttonActions: [{ label: "Switch Scene", value: "Volume" }], }); // Fetch scene items live so the list matches OBS state. @@ -620,6 +625,7 @@ export async function activate(ctx) { label: `${sourceName} (Toggle Visibility)`, icon_data: iconDataUrl || null, target: makeSourceToggleTarget(sceneName, sourceName), + buttonActions: [{ label: "Toggle Visibility", value: "ToggleMute" }], }); } } catch { @@ -646,6 +652,7 @@ export async function activate(ctx) { label: titleCaseAction(a), icon_data: iconDataUrl || null, target: makeActionTarget(a), + buttonActions: [{ label: titleCaseAction(a), value: "Volume" }], }); } @@ -672,6 +679,7 @@ export async function activate(ctx) { label: String(name), icon_data: iconDataUrl || null, target: { Integration: { integration_id: "obs", kind: "input", data: { input_name: String(name) } } }, + buttonActions: [{ label: "Toggle Mute", value: "ToggleMute" }], }); } diff --git a/src-tauri/builtin_plugins/wavelink/plugin.mjs b/src-tauri/builtin_plugins/wavelink/plugin.mjs index e37ee89..705e709 100644 --- a/src-tauri/builtin_plugins/wavelink/plugin.mjs +++ b/src-tauri/builtin_plugins/wavelink/plugin.mjs @@ -796,6 +796,9 @@ export async function activate(ctx) { id: "wavelink", name: "Wave Link", icon_data: iconDataUrl || null, + buttonActions: [ + { label: "Toggle Mute", value: "ToggleMute" }, + ], describeTarget: (target) => { const t = target?.Integration || target?.integration; const data = t?.data || {}; diff --git a/src/features/targets/targets.js b/src/features/targets/targets.js index 870a4b5..b286c4d 100644 --- a/src/features/targets/targets.js +++ b/src/features/targets/targets.js @@ -412,15 +412,21 @@ export function createTargetsFeature({ }; const actionLabel = (action, target = null) => { + // Check if the integration declares a custom label for this action const integ = integrationFromTarget(target); - if (action === "ToggleMute") { - if (integ?.integration_id === "hue") return "Toggle"; - return "Toggle Mute"; + if (integ?.integration_id) { + const pluginHost = getHost(); + const handler = pluginHost?.getIntegration(integ.integration_id); + if (Array.isArray(handler?.buttonActions)) { + const match = handler.buttonActions.find((a) => a.value === action); + if (match?.label) return match.label; + } } if (action === "MediaPlayPause") return "Media Play/Pause"; if (action === "MediaNextTrack") return "Media Next Track"; if (action === "MediaPrevTrack") return "Media Previous Track"; if (action === "MediaStop") return "Media Stop"; + if (action === "ToggleMute") return "Toggle Mute"; if (action === "Volume" && isBindingButton) return "Trigger"; return action; }; @@ -601,19 +607,32 @@ export function createTargetsFeature({ ]; } - const integ = targetOption?.target?.Integration || targetOption?.target?.integration; - if (integ?.integration_id === "hue") { - return [{ label: "Toggle", value: "ToggleMute", kind: "action" }]; + // Check per-target buttonActions first (set by plugins in getTargetOptions) + if (Array.isArray(targetOption?.buttonActions) && targetOption.buttonActions.length > 0) { + return targetOption.buttonActions.map((a) => ({ + label: a.label || a.value || "Action", + value: a.value || "Volume", + kind: "action", + icon_data: a.icon_data || null, + })); } - if (integ?.integration_id === "wavelink") { - const k = String(integ.kind || "").toLowerCase(); - // Wave Link source targets should only allow mute toggle. - if (k === "mix" || k === "channel" || k === "channel_mix") { - return [{ label: "Toggle Mute", value: "ToggleMute", kind: "action" }]; + + // Then check integration-level buttonActions (set by plugins in registerIntegration) + const integ = targetOption?.target?.Integration || targetOption?.target?.integration; + if (integ?.integration_id) { + const pluginHost = getHost(); + const handler = pluginHost?.getIntegration(integ.integration_id); + if (Array.isArray(handler?.buttonActions) && handler.buttonActions.length > 0) { + return handler.buttonActions.map((a) => ({ + label: a.label || a.value || "Action", + value: a.value || "Volume", + kind: "action", + icon_data: a.icon_data || null, + })); } - return [{ label: "Toggle Mute", value: "ToggleMute", kind: "action" }]; } + // Default fallback for integrations without declared actions return [ { label: "Trigger", value: "Volume", kind: "action" }, { label: "Toggle Mute", value: "ToggleMute", kind: "action" }, @@ -688,13 +707,18 @@ export function createTargetsFeature({ nav: o.nav, }; } - return { + const mapped = { label: o.label || "Integration Target", icon_data: o.icon_data || handler?.icon_data || null, kind: o.kind || "integration-target", value: targetKey((o.target?.Integration || o.target?.integration) || {}), target: o.target, }; + // Carry per-target buttonActions from plugin's getTargetOptions + if (Array.isArray(o.buttonActions) && o.buttonActions.length > 0) { + mapped.buttonActions = o.buttonActions; + } + return mapped; }); openTargetPanel( From 5a4cee6ecb843f2fee554aedb53b9e3a50ee35b3 Mon Sep 17 00:00:00 2001 From: Alex Hansen Date: Sun, 12 Apr 2026 21:16:15 -0400 Subject: [PATCH 02/25] feat: add --devtools flag to open devtools in release builds Enable the tauri devtools feature and add support for opening the webview inspector via --devtools CLI flag or MIDIMASTER_DEVTOOLS=1 environment variable. Useful for plugin development and debugging. Co-Authored-By: Claude Opus 4.6 --- src-tauri/Cargo.toml | 2 +- src-tauri/src/main.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8278cca..6e266cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ description = "Bind MIDI controls to system audio, devices, and plugin integrati tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "2", features = ["tray-icon", "devtools"] } tauri-plugin-window-state = "2.0.0" tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d36ade7..6d16d0d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1612,6 +1612,15 @@ fn main() { ) .build(app)?; + // Open devtools if --devtools flag or MIDIMASTER_DEVTOOLS env var is set + let open_devtools = std::env::args().any(|a| a == "--devtools") + || std::env::var("MIDIMASTER_DEVTOOLS").map_or(false, |v| v == "1"); + if open_devtools { + if let Some(w) = app.get_webview_window("main") { + w.open_devtools(); + } + } + let app_handle = app.handle().clone(); if let Some(main_window) = app.get_webview_window("main") { let app_handle = app_handle.clone(); From c7240b06b32dc8f25b65dbb3a55dfe923b738318 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:05:26 -0400 Subject: [PATCH 03/25] fix(ui): preserve plugin action selection labels and clean OBS target names --- src-tauri/builtin_plugins/obs/plugin.mjs | 6 ++-- src/features/targets/targets.js | 38 ++++++++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src-tauri/builtin_plugins/obs/plugin.mjs b/src-tauri/builtin_plugins/obs/plugin.mjs index 02735fd..313eb63 100644 --- a/src-tauri/builtin_plugins/obs/plugin.mjs +++ b/src-tauri/builtin_plugins/obs/plugin.mjs @@ -566,7 +566,7 @@ export async function activate(ctx) { let label = (typeof data.label === "string" && data.label.trim()) ? data.label : ""; if (!label) { if (t?.kind === "input") label = String(data.input_name || "OBS Input"); - else if (t?.kind === "source") label = `${data.source_name || "Source"} (Toggle Visibility)`; + else if (t?.kind === "source") label = String(data.source_name || "Source"); else if (t?.kind === "scene") label = String(data.scene_name || "OBS Scene"); else if (t?.kind === "action") label = titleCaseAction(data.action || "Action"); else label = "OBS Studio"; @@ -607,7 +607,7 @@ export async function activate(ctx) { const sceneName = String(nav.sceneName); opts.push({ - label: `Switch to ${sceneName}`, + label: String(sceneName), icon_data: iconDataUrl || null, target: makeSceneTarget(sceneName), buttonActions: [{ label: "Switch Scene", value: "Volume" }], @@ -622,7 +622,7 @@ export async function activate(ctx) { const sourceName = item?.sourceName; if (!sourceName) continue; opts.push({ - label: `${sourceName} (Toggle Visibility)`, + label: String(sourceName), icon_data: iconDataUrl || null, target: makeSourceToggleTarget(sceneName, sourceName), buttonActions: [{ label: "Toggle Visibility", value: "ToggleMute" }], diff --git a/src/features/targets/targets.js b/src/features/targets/targets.js index b286c4d..0bb1315 100644 --- a/src/features/targets/targets.js +++ b/src/features/targets/targets.js @@ -412,8 +412,11 @@ export function createTargetsFeature({ }; const actionLabel = (action, target = null) => { - // Check if the integration declares a custom label for this action const integ = integrationFromTarget(target); + const persistedActionLabel = String(integ?.data?.action_label || "").trim(); + if (persistedActionLabel) return persistedActionLabel; + + // Check if the integration declares a custom label for this action if (integ?.integration_id) { const pluginHost = getHost(); const handler = pluginHost?.getIntegration(integ.integration_id); @@ -518,6 +521,12 @@ export function createTargetsFeature({ }; if (option.label) next.Integration.data.label = String(option.label); if (option.icon_data) next.Integration.data.icon_data = option.icon_data; + if (option.__selectedActionLabel) { + next.Integration.data.action_label = String(option.__selectedActionLabel); + } + if (option.__selectedActionValue) { + next.Integration.data.action_value = String(option.__selectedActionValue); + } return next; } return t; @@ -553,8 +562,24 @@ export function createTargetsFeature({ setDisplay(); }; - const selectOption = (option, action = null, emit = true) => { - if (action) selectedAction = action; + const selectOption = (option, actionChoice = null, emit = true) => { + let nextActionValue = null; + let nextActionLabel = null; + if (typeof actionChoice === "string") { + nextActionValue = actionChoice; + } else if (actionChoice && typeof actionChoice === "object") { + nextActionValue = String(actionChoice.value || ""); + nextActionLabel = String(actionChoice.label || "").trim() || null; + } + if (nextActionValue) { + selectedAction = nextActionValue; + } + if (nextActionLabel && option && typeof option === "object") { + option.__selectedActionLabel = nextActionLabel; + } + if (nextActionValue && option && typeof option === "object") { + option.__selectedActionValue = nextActionValue; + } const mapped = mapOptionToTarget(option); const key = targetIdentity(mapped); @@ -606,6 +631,9 @@ export function createTargetsFeature({ { label: "Media Stop", value: "MediaStop", kind: "action", icon_data: mediaStopIconData }, ]; } + if (targetOption?.kind === "master" || targetOption?.kind === "focus") { + return [{ label: "Toggle Mute", value: "ToggleMute", kind: "action" }]; + } // Check per-target buttonActions first (set by plugins in getTargetOptions) if (Array.isArray(targetOption?.buttonActions) && targetOption.buttonActions.length > 0) { @@ -654,7 +682,7 @@ export function createTargetsFeature({ const actionOptions = buildButtonActionOptions(targetOption); setTimeout(() => { openTargetPanel(actionOptions, selectedAction, "action", (actionOption) => { - selectOption(targetOption, actionOption.value); + selectOption(targetOption, actionOption); }, "Select Action"); }, 10); return false; @@ -736,7 +764,7 @@ export function createTargetsFeature({ const actionOptions = buildButtonActionOptions(opt); setTimeout(() => { openTargetPanel(actionOptions, selectedAction, "action", (actionOption) => { - selectOption(opt, actionOption.value); + selectOption(opt, actionOption); }, "Select Action"); }, 10); return false; From d8eeb01175cfec1736a0e8302c36d07ab34714bd Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:29:28 -0400 Subject: [PATCH 04/25] fix: auto-detect relative MIDI deltas and harden focus volume fallback --- src-tauri/src/bindings.rs | 184 ++++++++++++++++++++++++++++- src-tauri/src/commands/bindings.rs | 18 +-- src-tauri/src/main.rs | 69 +++++++++-- src-tauri/src/model.rs | 17 +++ src/features/bindings/bindings.js | 19 +++ src/main.js | 70 +++++++++-- 6 files changed, 347 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/bindings.rs b/src-tauri/src/bindings.rs index b73795e..365b5a6 100644 --- a/src-tauri/src/bindings.rs +++ b/src-tauri/src/bindings.rs @@ -1,4 +1,4 @@ -use crate::model::{Binding, MidiEvent, MidiMode, Profile}; +use crate::model::{Binding, MidiEvent, MidiMode, Profile, RelativeFormat}; use std::time::{Duration, Instant}; const RELATIVE_STEP: f32 = 0.02; @@ -15,6 +15,11 @@ pub struct BindingKey { pub struct BindingState { pub last_value: f32, pub last_update: Instant, + pub relative_auto_format: Option, + pub relative_seen_midpoint: bool, + pub relative_seen_sign_band: bool, + pub relative_seen_high_negative: bool, + pub relative_seen_low_negative_hint: bool, } impl BindingKey { @@ -79,7 +84,7 @@ pub fn apply_midi_event( let next_value = match binding.mode { MidiMode::Absolute => absolute_value(binding, event)?, MidiMode::Relative => { - let delta = relative_delta(event.value)?; + let delta = relative_delta(binding, event.value, state)?; (state.last_value + (delta as f32 * RELATIVE_STEP)).clamp(0.0, 1.0) } }; @@ -101,7 +106,87 @@ fn absolute_value(binding: &Binding, event: &MidiEvent) -> Option { Some((event.value as f32) / 127.0) } -fn relative_delta(value: u8) -> Option { +fn relative_delta(binding: &Binding, value: u8, state: &mut BindingState) -> Option { + // Relative format is backend-auto-detected only for now. + let _ = binding; + if state.relative_auto_format.is_none() { + update_auto_relative_detection(value, state); + } + if let Some(detected) = &state.relative_auto_format { + return decode_relative_delta(detected, value); + } + decode_relative_delta_auto_fallback(value, state.relative_seen_midpoint) +} + +fn update_auto_relative_detection(value: u8, state: &mut BindingState) { + match value { + 63 => state.relative_seen_low_negative_hint = true, + 64 => state.relative_seen_midpoint = true, + 65..=95 => state.relative_seen_sign_band = true, + 96..=127 => state.relative_seen_high_negative = true, + _ => {} + } + + state.relative_auto_format = if state.relative_seen_high_negative { + // High negative-band values strongly indicate two's-complement + // (e.g. 127 == -1 on many endless encoders/faders). + Some(RelativeFormat::TwosComplement) + } else if state.relative_seen_low_negative_hint { + // 63 is a common "-1" marker in binary offset mode. + Some(RelativeFormat::BinaryOffset) + } else if state.relative_seen_midpoint && state.relative_seen_sign_band { + // Seeing midpoint + 65..95 usually indicates binary-offset style data. + Some(RelativeFormat::BinaryOffset) + } else if state.relative_seen_sign_band { + // 65..95 without midpoint is most often sign-magnitude. + Some(RelativeFormat::SignMagnitude) + } else { + None + }; +} + +fn decode_relative_delta_auto_fallback(value: u8, saw_midpoint: bool) -> Option { + match value { + 0 | 64 => Some(0), + 1..=62 => Some(value as i8), + // Keep this safe for binary-offset controllers even before auto-lock. + 63 => Some(-1), + // 127 is the problematic "down one" token for many two's-complement devices. + 96..=127 => Some((value as i16 - 128) as i8), + 65..=95 if saw_midpoint => Some((value - 64) as i8), + 65..=95 => Some(-((value - 64) as i8)), + _ => None, + } +} + +fn decode_relative_delta(format: &RelativeFormat, value: u8) -> Option { + match format { + RelativeFormat::Auto => None, + RelativeFormat::TwosComplement => decode_twos_complement(value), + RelativeFormat::BinaryOffset => decode_binary_offset(value), + RelativeFormat::SignMagnitude => decode_sign_magnitude(value), + } +} + +fn decode_twos_complement(value: u8) -> Option { + match value { + 0 | 64 => Some(0), + 1..=63 => Some(value as i8), + 65..=127 => Some((value as i16 - 128) as i8), + _ => None, + } +} + +fn decode_binary_offset(value: u8) -> Option { + match value { + 0 | 64 => Some(0), + 1..=63 => Some(-((64 - value) as i8)), + 65..=127 => Some((value - 64) as i8), + _ => None, + } +} + +fn decode_sign_magnitude(value: u8) -> Option { match value { 0 | 64 => Some(0), 1..=63 => Some(value as i8), @@ -109,3 +194,96 @@ fn relative_delta(value: u8) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + BindingAction, BindingControlKind, BindingTarget, MidiControl, MidiMessageType, + }; + + fn sample_binding(mode: MidiMode, relative_format: RelativeFormat) -> Binding { + Binding { + id: "test-binding".to_string(), + name: "Test".to_string(), + device_id: "midi:0".to_string(), + control: MidiControl { + channel: 0, + controller: 1, + msg_type: MidiMessageType::ControlChange, + }, + control_kind: BindingControlKind::Continuous, + targets: vec![BindingTarget::Master], + target: BindingTarget::Master, + action: BindingAction::Volume, + mode, + relative_format, + deadzone: 0.0, + debounce_ms: 0, + mute_control: None, + assign_control: None, + assign_mode: crate::model::AssignMode::Add, + } + } + + fn sample_state(last_value: f32) -> BindingState { + BindingState { + last_value, + last_update: Instant::now() + .checked_sub(Duration::from_secs(1)) + .unwrap_or_else(Instant::now), + relative_auto_format: None, + relative_seen_midpoint: false, + relative_seen_sign_band: false, + relative_seen_high_negative: false, + relative_seen_low_negative_hint: false, + } + } + + fn sample_event(value: u8) -> MidiEvent { + MidiEvent { + device_id: "midi:0".to_string(), + channel: 0, + controller: 1, + value, + value_14: None, + msg_type: MidiMessageType::ControlChange, + } + } + + #[test] + fn relative_format_twos_complement_maps_single_step_down_from_127() { + let binding = sample_binding(MidiMode::Relative, RelativeFormat::TwosComplement); + let mut state = sample_state(0.5); + let next = apply_midi_event(&binding, &sample_event(127), &mut state).expect("value"); + assert!((next - 0.48).abs() < 0.0001); + } + + #[test] + fn relative_format_binary_offset_maps_63_to_minus_one() { + let binding = sample_binding(MidiMode::Relative, RelativeFormat::BinaryOffset); + let mut state = sample_state(0.5); + let next = apply_midi_event(&binding, &sample_event(63), &mut state).expect("value"); + assert!((next - 0.48).abs() < 0.0001); + } + + #[test] + fn relative_format_sign_magnitude_maps_65_to_minus_one() { + let binding = sample_binding(MidiMode::Relative, RelativeFormat::SignMagnitude); + let mut state = sample_state(0.5); + let next = apply_midi_event(&binding, &sample_event(65), &mut state).expect("value"); + assert!((next - 0.48).abs() < 0.0001); + } + + #[test] + fn relative_auto_detect_uses_twos_for_127() { + let binding = sample_binding(MidiMode::Relative, RelativeFormat::Auto); + let mut state = sample_state(0.5); + let next = apply_midi_event(&binding, &sample_event(127), &mut state).expect("value"); + assert!((next - 0.48).abs() < 0.0001); + assert_eq!( + state.relative_auto_format, + Some(RelativeFormat::TwosComplement) + ); + } +} diff --git a/src-tauri/src/commands/bindings.rs b/src-tauri/src/commands/bindings.rs index d7cd0b4..17c83ef 100644 --- a/src-tauri/src/commands/bindings.rs +++ b/src-tauri/src/commands/bindings.rs @@ -171,16 +171,8 @@ fn apply_binding_action_internal( } } (model::BindingAction::Volume, model::BindingTarget::Focus) => { - if state.audio.focused_session().ok().flatten().is_some() { - if let Err(err) = state.audio.set_focused_session_volume(value) { - run_logger::warn( - "bindings_cmd", - "apply_action_volume_focus_failed", - &format!("binding_id={} error={}", binding.id, err), - ); - } else { - any_applied = true; - } + if state.apply_focus_volume_with_retry(&binding.id, value) { + any_applied = true; } } (model::BindingAction::Volume, model::BindingTarget::Session { session_id }) => { @@ -340,13 +332,15 @@ pub fn add_binding(state: State, mut binding: Binding) -> Result<(), S "bindings_cmd", "add_requested", &format!( - "binding_id={} device_id={} channel={} controller={} action={:?} control_kind={:?}", + "binding_id={} device_id={} channel={} controller={} action={:?} control_kind={:?} mode={:?} relative_format={:?}", binding.id, binding.device_id, binding.control.channel, binding.control.controller, binding.action, - binding.control_kind + binding.control_kind, + binding.mode, + binding.relative_format ), ); binding.ensure_targets(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6d16d0d..3457b86 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -77,6 +77,7 @@ struct AppState { active_profile: Mutex>, binding_state: Arc>>, feedback_values: Arc>>, + focus_volume_failure_logs: Mutex>, mute_transition_until: Mutex>, last_target_mute_state: Mutex>, learn_pending: Mutex, @@ -88,6 +89,60 @@ struct AppState { } impl AppState { + fn clear_focus_volume_failure_log(&self, binding_id: &str) { + if let Ok(mut logs) = self.focus_volume_failure_logs.lock() { + logs.remove(binding_id); + } + } + + fn should_log_focus_volume_failure(&self, binding_id: &str) -> bool { + const LOG_THROTTLE: Duration = Duration::from_secs(2); + let now = Instant::now(); + if let Ok(mut logs) = self.focus_volume_failure_logs.lock() { + if let Some(last) = logs.get(binding_id) { + if now.duration_since(*last) < LOG_THROTTLE { + return false; + } + } + logs.insert(binding_id.to_string(), now); + return true; + } + true + } + + pub(crate) fn apply_focus_volume_with_retry(&self, binding_id: &str, volume: f32) -> bool { + if self.audio.set_focused_session_volume(volume).is_ok() { + self.clear_focus_volume_failure_log(binding_id); + return true; + } + + let fallback_focus = self.audio.focused_session().ok().flatten(); + if let Some(ref session) = fallback_focus { + if self.audio.set_session_volume(&session.id, volume).is_ok() { + self.clear_focus_volume_failure_log(binding_id); + run_logger::info( + "bindings", + "set_focus_volume_fallback_applied", + &format!("binding_id={} session_id={}", binding_id, session.id), + ); + return true; + } + } + + if self.should_log_focus_volume_failure(binding_id) { + run_logger::error( + "bindings", + "set_focus_volume_failed", + &format!( + "binding_id={} error=Focused session not found; fallback_session_present={}", + binding_id, + fallback_focus.is_some() + ), + ); + } + false + } + fn apply_osd_settings(app: &AppHandle, settings: &OsdSettings) { let Some(osd_window) = app.get_webview_window("osd") else { return; @@ -684,6 +739,11 @@ impl AppState { let state = states.entry(key.clone()).or_insert_with(|| BindingState { last_value: 0.0, last_update: Instant::now(), + relative_auto_format: None, + relative_seen_midpoint: false, + relative_seen_sign_band: false, + relative_seen_high_negative: false, + relative_seen_low_negative_hint: false, }); apply_midi_event(&binding, &event, state) }; @@ -975,13 +1035,7 @@ impl AppState { } } model::BindingTarget::Focus => { - if let Err(err) = self.audio.set_focused_session_volume(volume) { - run_logger::error( - "bindings", - "set_focus_volume_failed", - &format!("binding_id={} error={}", binding.id, err), - ); - } else { + if self.apply_focus_volume_with_retry(&binding.id, volume) { any_applied = true; } } @@ -1545,6 +1599,7 @@ fn main() { active_profile: Mutex::new(None), binding_state: Arc::new(Mutex::new(HashMap::new())), feedback_values: Arc::new(Mutex::new(HashMap::new())), + focus_volume_failure_logs: Mutex::new(HashMap::new()), mute_transition_until: Mutex::new(HashMap::new()), last_target_mute_state: Mutex::new(HashMap::new()), learn_pending: Mutex::new(false), diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index 1fa774b..da77f9a 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -92,6 +92,20 @@ impl Default for MidiMode { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum RelativeFormat { + Auto, + TwosComplement, + BinaryOffset, + SignMagnitude, +} + +impl Default for RelativeFormat { + fn default() -> Self { + RelativeFormat::Auto + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum BindingAction { Volume, @@ -378,6 +392,8 @@ pub struct Binding { #[serde(default)] pub action: BindingAction, pub mode: MidiMode, + #[serde(default)] + pub relative_format: RelativeFormat, pub deadzone: f32, pub debounce_ms: u64, #[serde(default)] @@ -573,6 +589,7 @@ mod tests { target: BindingTarget::Master, action: BindingAction::Volume, mode: MidiMode::Absolute, + relative_format: RelativeFormat::Auto, deadzone: 0.0, debounce_ms: 0, mute_control: None, diff --git a/src/features/bindings/bindings.js b/src/features/bindings/bindings.js index a70e961..012d0d0 100644 --- a/src/features/bindings/bindings.js +++ b/src/features/bindings/bindings.js @@ -122,6 +122,21 @@ export function createBindingsFeature({ return "Auto"; } + function normalizeRelativeFormat(raw) { + const value = String(raw || "Auto"); + if (value === "Auto") return value; + return "Auto"; + } + + function ensureBindingShape(binding) { + if (!binding || typeof binding !== "object") return; + if (!binding.mode || (binding.mode !== "Absolute" && binding.mode !== "Relative")) { + binding.mode = "Absolute"; + } + // Backend auto-detect is always used for relative controls. + binding.relative_format = "Auto"; + } + function effectiveIsButton(binding) { const controlKind = normalizeControlKind(binding?.control_kind); if (controlKind === "Button") return true; @@ -398,6 +413,7 @@ export function createBindingsFeature({ } async function persistBinding(binding) { + ensureBindingShape(binding); await invoke("add_binding", { binding }); await saveProfile(); try { @@ -588,6 +604,7 @@ export function createBindingsFeature({ bindings.forEach((binding, index) => { try { + ensureBindingShape(binding); setTargets(binding, getTargets(binding)); const item = document.createElement("div"); item.className = "list-item binding-item"; @@ -748,10 +765,12 @@ export function createBindingsFeature({ } else if (nextModeValue === "fader_rel") { binding.control_kind = "Continuous"; binding.mode = "Relative"; + binding.relative_format = "Auto"; binding.action = "Volume"; } else { binding.control_kind = "Continuous"; binding.mode = "Absolute"; + binding.relative_format = "Auto"; binding.action = "Volume"; } diff --git a/src/main.js b/src/main.js index a924a99..e769501 100644 --- a/src/main.js +++ b/src/main.js @@ -210,6 +210,8 @@ function normalizeBinding(binding) { if (!binding || typeof binding !== "object") return binding; const out = { ...binding }; setBindingTargets(out, getBindingTargets(out)); + out.mode = (out.mode === "Relative") ? "Relative" : "Absolute"; + out.relative_format = "Auto"; if (out.assign_mode !== "Replace") out.assign_mode = "Add"; return out; } @@ -810,6 +812,7 @@ const bindingMuteValues = {}; // Track last known mute per binding ID (from feed let lastVolumeUpdateAt = 0; const osdBindingValues = new Map(); +const osdRelativeAutoFormatByBinding = new Map(); let osdSettings = { ...defaultOsdSettings }; let monitorOptions = []; let appSettings = { @@ -1095,16 +1098,66 @@ function createTargetIcon(option) { return targetsFeature?.createTargetIcon?.(option) || document.createElement("span"); } -function relativeDelta(value) { - if (value === 0 || value === 64) { - return 0; - } - if (value >= 1 && value <= 63) { +function normalizeRelativeFormat(raw) { + const value = String(raw || "Auto"); + if ( + value === "Auto" + || value === "TwosComplement" + || value === "BinaryOffset" + || value === "SignMagnitude" + ) { return value; } - if (value >= 65 && value <= 127) { - return -(value - 64); + return "Auto"; +} + +function decodeRelativeTwosComplement(value) { + if (value === 0 || value === 64) return 0; + if (value >= 1 && value <= 63) return value; + if (value >= 65 && value <= 127) return value - 128; + return null; +} + +function decodeRelativeBinaryOffset(value) { + if (value === 0 || value === 64) return 0; + if (value >= 1 && value <= 63) return -(64 - value); + if (value >= 65 && value <= 127) return value - 64; + return null; +} + +function decodeRelativeSignMagnitude(value) { + if (value === 0 || value === 64) return 0; + if (value >= 1 && value <= 63) return value; + if (value >= 65 && value <= 127) return -(value - 64); + return null; +} + +function detectRelativeFormatAuto(value, previousFormat) { + if (previousFormat && previousFormat !== "Auto") { + return previousFormat; + } + if (value >= 96 && value <= 127) return "TwosComplement"; + if (value === 63) return "BinaryOffset"; + if (value >= 65 && value <= 95) return "SignMagnitude"; + return null; +} + +function decodeRelativeDelta(binding, value) { + const configured = normalizeRelativeFormat(binding?.relative_format); + let format = configured; + if (format === "Auto") { + const key = String(binding?.id || ""); + const previouslyDetected = key ? osdRelativeAutoFormatByBinding.get(key) : null; + const detected = detectRelativeFormatAuto(value, previouslyDetected); + if (detected && key) { + osdRelativeAutoFormatByBinding.set(key, detected); + } + format = detected || previouslyDetected || "TwosComplement"; } + + if (format === "TwosComplement") return decodeRelativeTwosComplement(value); + if (format === "BinaryOffset") return decodeRelativeBinaryOffset(value); + if (format === "SignMagnitude") return decodeRelativeSignMagnitude(value); return null; } @@ -1135,7 +1188,7 @@ function resolveOsdVolume(binding, payload) { return null; } if (binding.mode === "Relative") { - const delta = relativeDelta(payload.value); + const delta = decodeRelativeDelta(binding, payload.value); if (delta == null) { return null; } @@ -1443,6 +1496,7 @@ function createBindingFromLearn(payload) { target: "Unset", action: isButton ? "ToggleMute" : "Volume", mode: "Absolute", + relative_format: "Auto", deadzone: 0, debounce_ms: 0, mute_control: null, From 5aae2757ac6c896b44a87bf68994720b60220893 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:30:26 -0400 Subject: [PATCH 05/25] chore: bump version to 1.2.0 --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6e266cd..24d5d31 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.1.0" +version = "1.2.0" edition = "2021" default-run = "midimaster" license = "MIT" From ca070b17fa53aec044daace82cb7b6905da1ebaf Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:31:42 -0400 Subject: [PATCH 06/25] chore: sync Cargo.lock for 1.2.0 --- src-tauri/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f23ac18..3689480 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "midimaster" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "base64 0.22.1", From 70f5f311ee8c80f807a1782431748f1bd7dac564 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:55:57 -0400 Subject: [PATCH 07/25] fix(ui): disable default context menu on right click --- src/main.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main.js b/src/main.js index e769501..482933a 100644 --- a/src/main.js +++ b/src/main.js @@ -36,6 +36,11 @@ let targetsFeature = null; let osdFeature = null; let midiFeature = null; +// Keep the app feeling native by disabling the default browser context menu. +document.addEventListener("contextmenu", (event) => { + event.preventDefault(); +}); + async function startPluginHostIfNeeded() { if (isOsdWindow) return; @@ -1908,4 +1913,3 @@ window.addEventListener("load", () => { window.addEventListener("beforeunload", () => { invoke("stop_midi_device").catch(() => { }); }); - From f41f9750588794dbe827e90c3e01d7dc5e3d0b47 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:07:14 -0400 Subject: [PATCH 08/25] feat(hotkeys): add hotkey target learn flow and trigger support --- src-tauri/src/bindings.rs | 1 + src-tauri/src/main.rs | 38 +++++- src-tauri/src/model.rs | 15 +++ src-tauri/src/runtime_helpers.rs | 105 +++++++++++++++++ src/features/bindings/bindings.js | 188 +++++++++++++++++++++++++++++- src/features/targets/targets.js | 33 +++++- src/main.js | 6 +- 7 files changed, 374 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/bindings.rs b/src-tauri/src/bindings.rs index 365b5a6..18e0ca8 100644 --- a/src-tauri/src/bindings.rs +++ b/src-tauri/src/bindings.rs @@ -223,6 +223,7 @@ mod tests { mute_control: None, assign_control: None, assign_mode: crate::model::AssignMode::Add, + hotkey: None, } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3457b86..b778230 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -27,7 +27,7 @@ use device_target::{parse_device_target, DeviceTargetKind}; use midi::MidiManager; use model::{LearnedControl, MidiEvent, OsdSettings, Profile}; use monitors::resolve_monitor_for_osd; -use runtime_helpers::{classify_learned_control, send_media_key, LearnCandidate}; +use runtime_helpers::{classify_learned_control, send_hotkey, send_media_key, LearnCandidate}; use windows_autostart::set_windows_autostart; use profile_store::ProfileStore; @@ -788,6 +788,31 @@ impl AppState { return Ok(()); } + if binding.action == model::BindingAction::Hotkey { + if event.value == 0 { + run_logger::debug( + "bindings", + "hotkey_action_ignored_release", + &format!("binding_id={} action={:?}", binding.id, binding.action), + ); + return Ok(()); + } + if let Some(hotkey) = &binding.hotkey { + if !hotkey.keys.is_empty() { + send_hotkey(&hotkey.keys); + run_logger::info( + "bindings", + "hotkey_action_sent", + &format!( + "binding_id={} action={:?} hotkey={}", + binding.id, binding.action, hotkey.display + ), + ); + } + } + return Ok(()); + } + // Handle toggle mute action for button bindings if binding.action == model::BindingAction::ToggleMute { // Mark user activity to prevent stale feedback loop @@ -930,7 +955,9 @@ impl AppState { let _ = app.emit("integration_binding_triggered", payload); any_applied = true; } - model::BindingTarget::Unset | model::BindingTarget::MediaControl => {} + model::BindingTarget::Unset + | model::BindingTarget::MediaControl + | model::BindingTarget::Hotkey => {} } } @@ -1099,7 +1126,9 @@ impl AppState { let _ = app.emit("integration_binding_triggered", payload); any_applied = true; } - model::BindingTarget::Unset | model::BindingTarget::MediaControl => {} + model::BindingTarget::Unset + | model::BindingTarget::MediaControl + | model::BindingTarget::Hotkey => {} } } @@ -1216,6 +1245,7 @@ impl AppState { | model::BindingAction::MediaNextTrack | model::BindingAction::MediaPrevTrack | model::BindingAction::MediaStop + | model::BindingAction::Hotkey ) { continue; } @@ -1277,6 +1307,7 @@ impl AppState { } model::BindingTarget::Unset => None, model::BindingTarget::MediaControl => None, + model::BindingTarget::Hotkey => None, model::BindingTarget::Integration { .. } => None, } } else { @@ -1345,6 +1376,7 @@ impl AppState { } model::BindingTarget::Unset => None, model::BindingTarget::MediaControl => None, + model::BindingTarget::Hotkey => None, model::BindingTarget::Integration { .. } => None, } }; diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index da77f9a..9574c38 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -114,6 +114,7 @@ pub enum BindingAction { MediaNextTrack, MediaPrevTrack, MediaStop, + Hotkey, } impl Default for BindingAction { @@ -122,6 +123,14 @@ impl Default for BindingAction { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HotkeyMapping { + #[serde(default)] + pub keys: Vec, + #[serde(default)] + pub display: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum AssignMode { Add, @@ -162,6 +171,7 @@ pub enum BindingTarget { data: serde_json::Value, }, MediaControl, + Hotkey, Unset, } @@ -193,6 +203,7 @@ fn binding_target_from_value(v: serde_json::Value) -> Result Ok(BindingTarget::Master), "Focus" => Ok(BindingTarget::Focus), "MediaControl" => Ok(BindingTarget::MediaControl), + "Hotkey" => Ok(BindingTarget::Hotkey), "Unset" => Ok(BindingTarget::Unset), other => Err(format!("Unknown BindingTarget string: {}", other)), }; @@ -260,6 +271,7 @@ fn binding_target_from_value(v: serde_json::Value) -> Result Ok(BindingTarget::Unset), "MediaControl" => Ok(BindingTarget::MediaControl), + "Hotkey" => Ok(BindingTarget::Hotkey), // New generic integration target "Integration" => { @@ -402,6 +414,8 @@ pub struct Binding { pub assign_control: Option, #[serde(default)] pub assign_mode: AssignMode, + #[serde(default)] + pub hotkey: Option, } impl Binding { @@ -595,6 +609,7 @@ mod tests { mute_control: None, assign_control: None, assign_mode: AssignMode::Add, + hotkey: None, }; let json = serde_json::to_value(binding).expect("binding should serialize"); diff --git a/src-tauri/src/runtime_helpers.rs b/src-tauri/src/runtime_helpers.rs index d7289d6..c5c4c9e 100644 --- a/src-tauri/src/runtime_helpers.rs +++ b/src-tauri/src/runtime_helpers.rs @@ -8,6 +8,52 @@ pub(crate) struct LearnCandidate { pub saw_max: bool, } +#[cfg(target_os = "windows")] +fn key_name_to_vk(name: &str) -> Option { + let upper = name.trim().to_uppercase(); + match upper.as_str() { + "CTRL" | "CONTROL" => Some(0x11), + "SHIFT" => Some(0x10), + "ALT" | "OPTION" => Some(0x12), + "META" | "CMD" | "COMMAND" | "WIN" | "WINDOWS" => Some(0x5B), + "SPACE" => Some(0x20), + "ENTER" | "RETURN" => Some(0x0D), + "TAB" => Some(0x09), + "ESC" | "ESCAPE" => Some(0x1B), + "BACKSPACE" => Some(0x08), + "DELETE" | "DEL" => Some(0x2E), + "INSERT" => Some(0x2D), + "HOME" => Some(0x24), + "END" => Some(0x23), + "PAGEUP" => Some(0x21), + "PAGEDOWN" => Some(0x22), + "UP" | "ARROWUP" => Some(0x26), + "DOWN" | "ARROWDOWN" => Some(0x28), + "LEFT" | "ARROWLEFT" => Some(0x25), + "RIGHT" | "ARROWRIGHT" => Some(0x27), + "CAPSLOCK" => Some(0x14), + "PRINTSCREEN" => Some(0x2C), + "SCROLLLOCK" => Some(0x91), + "PAUSE" => Some(0x13), + _ => { + if upper.len() == 1 { + let b = upper.as_bytes()[0]; + if b.is_ascii_uppercase() || b.is_ascii_digit() { + return Some(b as u16); + } + } + if let Some(rest) = upper.strip_prefix('F') { + if let Ok(n) = rest.parse::() { + if (1..=24).contains(&n) { + return Some(0x70 + n as u16 - 1); + } + } + } + None + } + } +} + #[cfg(target_os = "windows")] pub(crate) fn send_media_key(vk: u16) { use windows::Win32::UI::Input::KeyboardAndMouse::{ @@ -46,11 +92,70 @@ pub(crate) fn send_media_key(vk: u16) { } } +#[cfg(target_os = "windows")] +pub(crate) fn send_hotkey(keys: &[String]) { + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, + VIRTUAL_KEY, + }; + + let mut vks: Vec = Vec::new(); + for key in keys { + if let Some(vk) = key_name_to_vk(key) { + if !vks.contains(&vk) { + vks.push(vk); + } + } + } + if vks.is_empty() { + return; + } + + let mut events: Vec = Vec::with_capacity(vks.len() * 2); + for vk in &vks { + events.push(INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(*vk), + wScan: 0, + dwFlags: KEYBD_EVENT_FLAGS(0), + time: 0, + dwExtraInfo: 0, + }, + }, + }); + } + for vk in vks.iter().rev() { + events.push(INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(*vk), + wScan: 0, + dwFlags: KEYEVENTF_KEYUP, + time: 0, + dwExtraInfo: 0, + }, + }, + }); + } + + unsafe { + SendInput(&events, std::mem::size_of::() as i32); + } +} + #[cfg(not(target_os = "windows"))] pub(crate) fn send_media_key(_vk: u16) { // no-op on unsupported platforms } +#[cfg(not(target_os = "windows"))] +pub(crate) fn send_hotkey(_keys: &[String]) { + // no-op on unsupported platforms +} + fn classify_cc_candidate(saw_zero: bool, saw_max: bool) -> model::BindingControlKind { if saw_zero && saw_max { model::BindingControlKind::Button diff --git a/src/features/bindings/bindings.js b/src/features/bindings/bindings.js index 012d0d0..6ddbccb 100644 --- a/src/features/bindings/bindings.js +++ b/src/features/bindings/bindings.js @@ -144,6 +144,10 @@ export function createBindingsFeature({ return binding?.control?.msg_type === "Note"; } + function isHotkeyTarget(target) { + return target === "Hotkey"; + } + function getTargets(binding) { if (!binding || typeof binding !== "object") return []; if (Array.isArray(binding.targets) && binding.targets.length > 0) { @@ -210,6 +214,9 @@ export function createBindingsFeature({ let configLearnField = null; let configLearnTimer = null; let transferPrompt = null; + let hotkeyLearnBindingId = null; + let hotkeyLearnCleanup = null; + const hotkeyModifiers = ["Ctrl", "Shift", "Alt", "Meta"]; const nameDrafts = new Map(); let pendingRerender = false; const defaultLearnPanelTitle = "Waiting for MIDI Input"; @@ -234,7 +241,11 @@ export function createBindingsFeature({ if (d.learnPanelMessage) d.learnPanelMessage.textContent = defaultLearnPanelMessage; if (d.learnPanelSpinner) d.learnPanelSpinner.classList.remove("hidden"); if (d.learnPanelActions) d.learnPanelActions.classList.add("hidden"); - if (d.learnPanelConfirm) d.learnPanelConfirm.textContent = "Transfer"; + if (d.learnPanelCancel) d.learnPanelCancel.textContent = "Cancel"; + if (d.learnPanelConfirm) { + d.learnPanelConfirm.textContent = "Transfer"; + d.learnPanelConfirm.classList.remove("hidden"); + } } function showLearnPanel() { @@ -263,8 +274,131 @@ export function createBindingsFeature({ if (d.learnPanelMessage) d.learnPanelMessage.textContent = message || ""; if (d.learnPanelSpinner) d.learnPanelSpinner.classList.add("hidden"); if (d.learnPanelActions) d.learnPanelActions.classList.remove("hidden"); - if (d.learnPanelConfirm) d.learnPanelConfirm.textContent = "Transfer"; + if (d.learnPanelCancel) d.learnPanelCancel.textContent = "Cancel"; + if (d.learnPanelConfirm) { + d.learnPanelConfirm.textContent = "Transfer"; + d.learnPanelConfirm.classList.remove("hidden"); + } + showLearnPanel(); + } + + function normalizeHotkeyMapping(rawHotkey) { + if (!rawHotkey || typeof rawHotkey !== "object") return null; + const keys = Array.isArray(rawHotkey.keys) + ? rawHotkey.keys + .map((key) => String(key || "").trim()) + .filter(Boolean) + : []; + if (keys.length === 0) return null; + const display = String(rawHotkey.display || "").trim() || keys.join("+"); + return { keys, display }; + } + + function normalizeHotkeyKey(event) { + const key = String(event?.key || "").trim(); + if (!key) return null; + const lower = key.toLowerCase(); + if (lower === "control") return "Ctrl"; + if (lower === "shift") return "Shift"; + if (lower === "alt") return "Alt"; + if (lower === "meta") return "Meta"; + if (lower === " ") return "Space"; + if (lower === "escape") return "Esc"; + if (lower === "arrowup") return "Up"; + if (lower === "arrowdown") return "Down"; + if (lower === "arrowleft") return "Left"; + if (lower === "arrowright") return "Right"; + if (key.length === 1) return key.toUpperCase(); + if (/^f\d{1,2}$/i.test(key)) return key.toUpperCase(); + return key.length <= 16 ? key[0].toUpperCase() + key.slice(1) : null; + } + + function isHotkeyModifier(key) { + return hotkeyModifiers.includes(key); + } + + function buildHotkeyMappingFromEvent(event) { + const key = normalizeHotkeyKey(event); + if (!key || isHotkeyModifier(key)) return null; + + const keys = []; + if (event.ctrlKey) keys.push("Ctrl"); + if (event.shiftKey) keys.push("Shift"); + if (event.altKey) keys.push("Alt"); + if (event.metaKey) keys.push("Meta"); + if (!keys.includes(key)) keys.push(key); + + return { + keys, + display: keys.join("+"), + }; + } + + function stopHotkeyLearn(result = null) { + if (hotkeyLearnCleanup) { + hotkeyLearnCleanup(); + hotkeyLearnCleanup = null; + } + hotkeyLearnBindingId = null; + hideLearnPanel(); + return result; + } + + async function startHotkeyLearn(binding) { + if (!binding || transferPrompt || configLearnField || hotkeyLearnBindingId) { + return null; + } + + hotkeyLearnBindingId = binding.id; + if (d.learnPanelTitle) d.learnPanelTitle.textContent = "Press Hotkey"; + if (d.learnPanelMessage) { + d.learnPanelMessage.textContent = "Press a key or combo (example: Ctrl+Shift+S)."; + } + if (d.learnPanelSpinner) d.learnPanelSpinner.classList.add("hidden"); + if (d.learnPanelActions) d.learnPanelActions.classList.remove("hidden"); + if (d.learnPanelCancel) d.learnPanelCancel.textContent = "Cancel"; + if (d.learnPanelConfirm) d.learnPanelConfirm.classList.add("hidden"); showLearnPanel(); + + return await new Promise((resolve) => { + let settled = false; + const finish = (mapping) => { + if (settled) return; + settled = true; + stopHotkeyLearn(mapping); + resolve(mapping); + }; + + const onCancel = () => finish(null); + const onOverlay = (event) => { + if (event.target === d.learnPanel) finish(null); + }; + const onKeydown = (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + finish(null); + return; + } + + const mapping = buildHotkeyMappingFromEvent(event); + if (!mapping) return; + finish(mapping); + }; + + window.addEventListener("keydown", onKeydown, true); + d.learnPanelCancel?.addEventListener("click", onCancel); + d.learnPanelClose?.addEventListener("click", onCancel); + d.learnPanel?.addEventListener("click", onOverlay); + + hotkeyLearnCleanup = () => { + window.removeEventListener("keydown", onKeydown, true); + d.learnPanelCancel?.removeEventListener("click", onCancel); + d.learnPanelClose?.removeEventListener("click", onCancel); + d.learnPanel?.removeEventListener("click", onOverlay); + }; + }); } function updateAuxLearnUi() { @@ -355,6 +489,7 @@ export function createBindingsFeature({ } function closeConfigModal() { + stopHotkeyLearn(); stopAuxLearn(); clearTransferPrompt(); closeAssignModeMenu(); @@ -606,6 +741,7 @@ export function createBindingsFeature({ try { ensureBindingShape(binding); setTargets(binding, getTargets(binding)); + binding.hotkey = normalizeHotkeyMapping(binding.hotkey); const item = document.createElement("div"); item.className = "list-item binding-item"; @@ -808,19 +944,54 @@ export function createBindingsFeature({ modeDropdown.appendChild(modeButton); modeDropdown.appendChild(modeMenu); - const targetSelect = buildTarget(getTargets(binding), isButton, binding.action); + const targetSelect = buildTarget( + getTargets(binding), + isButton, + binding.action, + binding.hotkey?.display || "", + ); targetSelect.addEventListener("change", async () => { + const previousTargets = getTargets(binding); + const previousHadHotkeyTarget = previousTargets.some(isHotkeyTarget); const selectedTargets = Array.isArray(targetSelect.__selectedTargets) ? targetSelect.__selectedTargets : (targetSelect.__selectedTarget ? [targetSelect.__selectedTarget] : []); setTargets(binding, selectedTargets); + const hasHotkeyTarget = selectedTargets.some(isHotkeyTarget); + const previousAction = binding.action; + const previousHotkey = normalizeHotkeyMapping(binding.hotkey); if (isButton) { - binding.action = targetSelect.dataset.action || binding.action || "ToggleMute"; + binding.action = hasHotkeyTarget + ? "Hotkey" + : (targetSelect.dataset.action || binding.action || "ToggleMute"); } else { binding.action = "Volume"; } + if (isButton && !hasHotkeyTarget && previousHadHotkeyTarget) { + binding.hotkey = null; + targetSelect?.setHotkeyDisplay?.(""); + if (binding.action === "Hotkey") { + binding.action = targetSelect.dataset.action || "ToggleMute"; + } + } + + if (isButton && hasHotkeyTarget && !previousHadHotkeyTarget) { + const learnedHotkey = await startHotkeyLearn(binding); + if (!learnedHotkey) { + setTargets(binding, previousTargets); + binding.action = previousAction || "ToggleMute"; + binding.hotkey = previousHotkey; + await invoke("add_binding", { binding }); + await saveProfile(); + renderBindings(); + return; + } + binding.hotkey = learnedHotkey; + targetSelect?.setHotkeyDisplay?.(binding.hotkey?.display || ""); + } + if (!isButton) { const primaryTarget = getPrimaryTarget(binding); const newVolume = (bindingLastValues[binding.id] != null) @@ -854,6 +1025,12 @@ export function createBindingsFeature({ try { getHost()?.setBindings?.(getB()); } catch { } + + // Hotkey target UX: force a fresh row render so the chip label updates + // immediately from "Not Set" to the learned combo. + if (isButton && hasHotkeyTarget) { + renderBindings(); + } }); const volumeSlider = document.createElement("input"); @@ -1255,18 +1432,21 @@ export function createBindingsFeature({ if (d.learnPanel) { d.learnPanel.addEventListener("click", (event) => { if (event.target !== d.learnPanel) return; + if (hotkeyLearnBindingId) return; if (!configBindingId) return; cancelAuxLearnFlow(); }); } if (d.learnPanelClose) { d.learnPanelClose.addEventListener("click", () => { + if (hotkeyLearnBindingId) return; if (!configBindingId) return; cancelAuxLearnFlow(); }); } if (d.learnPanelCancel) { d.learnPanelCancel.addEventListener("click", () => { + if (hotkeyLearnBindingId) return; if (!configBindingId) return; cancelAuxLearnFlow(); }); diff --git a/src/features/targets/targets.js b/src/features/targets/targets.js index 0bb1315..a249f85 100644 --- a/src/features/targets/targets.js +++ b/src/features/targets/targets.js @@ -27,6 +27,7 @@ export function createTargetsFeature({ let activeTargetPanelSelect = null; let activeTargetPanelBack = null; + const HOTKEY_ICON_DATA = "data:image/svg+xml;utf8,"; function mediaIconForAction(action) { if (action === "MediaNextTrack") return mediaNextTrackIconData; @@ -176,6 +177,7 @@ export function createTargetsFeature({ : (currentTarget === "Master" || currentTarget?.Master != null) ? "master" : (currentTarget === "Focus" || currentTarget?.Focus != null) ? "focus" : currentTarget === "MediaControl" ? "media-control" + : currentTarget === "Hotkey" ? "hotkey-target" : "placeholder" ); @@ -183,7 +185,7 @@ export function createTargetsFeature({ if (selectedKind === "integration-target") selectedValue = targetKey(integration); else if (selectedKind === "session") selectedValue = selectedAppName || selectedSessionKey || ""; else if (selectedKind === "device") selectedValue = selectedDeviceId || ""; - else if (selectedKind === "master" || selectedKind === "focus" || selectedKind === "media-control") selectedValue = selectedKind; + else if (selectedKind === "master" || selectedKind === "focus" || selectedKind === "media-control" || selectedKind === "hotkey-target") selectedValue = selectedKind; else if (selectedKind === "placeholder") selectedValue = "placeholder"; const options = [ @@ -208,6 +210,12 @@ export function createTargetsFeature({ icon_data: mediaPlayPauseIconData, kind: "media-control", }); + options.push({ + value: "hotkey-target", + label: "Hotkey", + icon_data: HOTKEY_ICON_DATA, + kind: "hotkey-target", + }); } if (pluginHost) { @@ -345,7 +353,7 @@ export function createTargetsFeature({ return { options, selectedValue, selectedKind, activeIntegrationOption }; } - function buildTargetSelect(currentTarget, isBindingButton = false, currentAction = "Volume") { + function buildTargetSelect(currentTarget, isBindingButton = false, currentAction = "Volume", currentHotkeyDisplay = "") { const container = document.createElement("div"); container.className = "target-dropdown binding-target-dropdown"; @@ -378,6 +386,7 @@ export function createTargetsFeature({ if (target === "Master" || target?.Master != null) return "master"; if (target === "Focus" || target?.Focus != null) return "focus"; if (target === "MediaControl") return "media-control"; + if (target === "Hotkey") return "hotkey-target"; const integration = target?.Integration || target?.integration; if (integration) { return `integration:${targetKey(integration)}`; @@ -396,6 +405,7 @@ export function createTargetsFeature({ }; let selectedTargets = normalizeTargets(currentTarget); + let hotkeyDisplay = String(currentHotkeyDisplay || ""); const targetDisplayCache = new Map(); let selectedAction = isBindingButton ? (currentAction || "ToggleMute") : "Volume"; @@ -429,6 +439,7 @@ export function createTargetsFeature({ if (action === "MediaNextTrack") return "Media Next Track"; if (action === "MediaPrevTrack") return "Media Previous Track"; if (action === "MediaStop") return "Media Stop"; + if (action === "Hotkey") return "Hotkey"; if (action === "ToggleMute") return "Toggle Mute"; if (action === "Volume" && isBindingButton) return "Trigger"; return action; @@ -437,6 +448,12 @@ export function createTargetsFeature({ const cachedDisplayForTarget = (target) => { const key = targetIdentity(target); const cached = targetDisplayCache.get(key); + if (target === "Hotkey") { + return { + label: hotkeyDisplay ? `Hotkey (${hotkeyDisplay})` : "Hotkey (Not Set)", + icon_data: cached?.icon_data ?? HOTKEY_ICON_DATA, + }; + } const resolved = resolveDisplay(target); const merged = { label: (resolved?.label || cached?.label || "Target"), @@ -540,6 +557,9 @@ export function createTargetsFeature({ if (option.kind === "media-control") { return "MediaControl"; } + if (option.kind === "hotkey-target") { + return "Hotkey"; + } if (option.kind === "device") { return { Device: { device_id: option.value } }; } @@ -582,6 +602,9 @@ export function createTargetsFeature({ } const mapped = mapOptionToTarget(option); + if (mapped === "Hotkey") { + selectedAction = "Hotkey"; + } const key = targetIdentity(mapped); const cachedLabel = String(option?.label || "").trim(); if (cachedLabel || option?.icon_data) { @@ -678,7 +701,7 @@ export function createTargetsFeature({ return false; } - if (isBindingButton) { + if (isBindingButton && targetOption.kind !== "hotkey-target") { const actionOptions = buildButtonActionOptions(targetOption); setTimeout(() => { openTargetPanel(actionOptions, selectedAction, "action", (actionOption) => { @@ -792,6 +815,10 @@ export function createTargetsFeature({ }); container.appendChild(button); + container.setHotkeyDisplay = (nextDisplay = "") => { + hotkeyDisplay = String(nextDisplay || ""); + setDisplay(); + }; return container; } diff --git a/src/main.js b/src/main.js index 482933a..534a695 100644 --- a/src/main.js +++ b/src/main.js @@ -218,6 +218,7 @@ function normalizeBinding(binding) { out.mode = (out.mode === "Relative") ? "Relative" : "Absolute"; out.relative_format = "Auto"; if (out.assign_mode !== "Replace") out.assign_mode = "Add"; + if (!out.hotkey || typeof out.hotkey !== "object") out.hotkey = null; return out; } @@ -1379,8 +1380,8 @@ function buildTargetOptions(currentTarget, isButton = false) { return targetsFeature?.buildTargetOptions?.(currentTarget, isButton); } -function buildTargetSelect(currentTarget, isBindingButton = false, currentAction = "Volume") { - return targetsFeature?.buildTargetSelect?.(currentTarget, isBindingButton, currentAction); +function buildTargetSelect(currentTarget, isBindingButton = false, currentAction = "Volume", currentHotkeyDisplay = "") { + return targetsFeature?.buildTargetSelect?.(currentTarget, isBindingButton, currentAction, currentHotkeyDisplay); } const LEARN_PANEL_DEFAULT_TITLE = "Waiting for MIDI Input"; @@ -1507,6 +1508,7 @@ function createBindingFromLearn(payload) { mute_control: null, assign_control: null, assign_mode: "Add", + hotkey: null, }; } From 18b219fd47456f5f424a73b985d568760ad7fbcd Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:07:54 -0400 Subject: [PATCH 09/25] chore(release): bump version to 1.3.0 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3689480..fbda38c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "midimaster" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 24d5d31..cffdcf9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.2.0" +version = "1.3.0" edition = "2021" default-run = "midimaster" license = "MIT" From 4acc61f2cb38628027772cc40b128d188eee31e9 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:23:41 -0400 Subject: [PATCH 10/25] feat(bindings): add set default device action for audio targets --- src-tauri/src/audio/mod.rs | 1 + src-tauri/src/audio/unsupported.rs | 4 + src-tauri/src/audio/windows.rs | 126 ++++++++++++++++++++++++++++- src-tauri/src/commands/bindings.rs | 21 ++++- src-tauri/src/main.rs | 40 +++++++++ src-tauri/src/model.rs | 12 +++ src/features/targets/targets.js | 29 ++++++- src/features/ui/dropdown_badges.js | 9 ++- src/styles/bindings-and-panels.css | 12 ++- 9 files changed, 243 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 2f6e0d9..a5a65c0 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -17,6 +17,7 @@ pub trait AudioBackend: Send + Sync { fn set_focused_session_mute(&self, muted: bool) -> anyhow::Result<()>; fn set_application_mute(&self, name: &str, muted: bool) -> anyhow::Result<()>; fn set_device_mute(&self, device_id: &str, muted: bool) -> anyhow::Result<()>; + fn set_default_device(&self, device_id: &str) -> anyhow::Result<()>; } #[cfg(target_os = "windows")] diff --git a/src-tauri/src/audio/unsupported.rs b/src-tauri/src/audio/unsupported.rs index 14bd6db..7662dd8 100644 --- a/src-tauri/src/audio/unsupported.rs +++ b/src-tauri/src/audio/unsupported.rs @@ -66,4 +66,8 @@ impl AudioBackend for UnsupportedAudioBackend { fn set_device_mute(&self, _device_id: &str, _muted: bool) -> Result<()> { Err(anyhow!("Audio backend not implemented on this OS")) } + + fn set_default_device(&self, _device_id: &str) -> Result<()> { + Err(anyhow!("Audio backend not implemented on this OS")) + } } diff --git a/src-tauri/src/audio/windows.rs b/src-tauri/src/audio/windows.rs index e1f47de..8bc38cd 100644 --- a/src-tauri/src/audio/windows.rs +++ b/src-tauri/src/audio/windows.rs @@ -11,7 +11,7 @@ use std::ffi::{OsStr, OsString}; use std::mem::size_of; use std::os::windows::ffi::{OsStrExt, OsStringExt}; use std::path::Path; -use windows::core::{Interface, PCWSTR, PWSTR}; +use windows::core::{IUnknown_Vtbl, Interface, GUID, HRESULT, PCWSTR, PWSTR}; use windows::Win32::Foundation::{CloseHandle, PROPERTYKEY, RPC_E_CHANGED_MODE}; use windows::Win32::Graphics::Gdi::{ DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER, @@ -19,8 +19,9 @@ use windows::Win32::Graphics::Gdi::{ }; use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; use windows::Win32::Media::Audio::{ - eCapture, eMultimedia, eRender, EDataFlow, IAudioSessionControl2, IAudioSessionManager2, - IMMDevice, IMMDeviceEnumerator, ISimpleAudioVolume, MMDeviceEnumerator, DEVICE_STATE_ACTIVE, + eCapture, eCommunications, eConsole, eMultimedia, eRender, EDataFlow, ERole, + IAudioSessionControl2, IAudioSessionManager2, IMMDevice, IMMDeviceEnumerator, + ISimpleAudioVolume, MMDeviceEnumerator, DEVICE_STATE_ACTIVE, WAVEFORMATEX, }; use windows::Win32::System::Com::StructuredStorage::{ PropVariantClear, PropVariantToStringAlloc, PROPVARIANT, @@ -311,6 +312,26 @@ impl AudioBackend for WindowsAudioBackend { Err(anyhow!("Device not found")) } + fn set_default_device(&self, device_id: &str) -> Result<()> { + let _com = init_com()?; + let enumerator = get_device_enumerator()?; + let (kind, raw_id) = parse_device_target(device_id); + let flow = match kind { + DeviceTargetKind::Playback => eRender, + DeviceTargetKind::Recording => eCapture, + }; + + let exists = enumerate_active_devices(&enumerator, flow)? + .iter() + .any(|(_, id)| id == raw_id); + if !exists { + return Err(anyhow!("Device not found")); + } + + set_default_audio_endpoint(raw_id)?; + Ok(()) + } + fn set_session_mute(&self, session_id: &str, muted: bool) -> Result<()> { let _com = init_com()?; let enumerator = get_device_enumerator()?; @@ -357,6 +378,105 @@ fn enumerate_active_devices( Ok(devices) } +fn set_default_audio_endpoint(device_id: &str) -> Result<()> { + let policy: IPolicyConfig = + unsafe { CoCreateInstance(&CLSID_POLICY_CONFIG_CLIENT, None, CLSCTX_ALL) }?; + let wide = to_wide(device_id); + unsafe { + policy.set_default_endpoint(PCWSTR(wide.as_ptr()), eConsole)?; + policy.set_default_endpoint(PCWSTR(wide.as_ptr()), eMultimedia)?; + policy.set_default_endpoint(PCWSTR(wide.as_ptr()), eCommunications)?; + } + Ok(()) +} + +fn to_wide(value: &str) -> Vec { + OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +const CLSID_POLICY_CONFIG_CLIENT: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9); + +#[repr(transparent)] +#[derive(Clone, PartialEq, Eq)] +struct IPolicyConfig(windows::core::IUnknown); + +unsafe impl Interface for IPolicyConfig { + type Vtable = IPolicyConfig_Vtbl; + const IID: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8); +} + +impl IPolicyConfig { + unsafe fn set_default_endpoint( + &self, + device_id: PCWSTR, + role: ERole, + ) -> windows::core::Result<()> { + (Interface::vtable(self).SetDefaultEndpoint)(Interface::as_raw(self), device_id, role).ok() + } +} + +#[repr(C)] +#[allow(non_snake_case)] +struct IPolicyConfig_Vtbl { + pub base__: IUnknown_Vtbl, + pub GetMixFormat: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + *mut *mut WAVEFORMATEX, + ) -> HRESULT, + pub GetDeviceFormat: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + i32, + *mut *mut WAVEFORMATEX, + ) -> HRESULT, + pub ResetDeviceFormat: unsafe extern "system" fn(*mut core::ffi::c_void, PCWSTR) -> HRESULT, + pub SetDeviceFormat: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + *mut WAVEFORMATEX, + *mut WAVEFORMATEX, + ) -> HRESULT, + pub GetProcessingPeriod: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + i32, + *mut i64, + *mut i64, + ) -> HRESULT, + pub SetProcessingPeriod: + unsafe extern "system" fn(*mut core::ffi::c_void, PCWSTR, *mut i64) -> HRESULT, + pub GetShareMode: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + *mut core::ffi::c_void, + ) -> HRESULT, + pub SetShareMode: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + *mut core::ffi::c_void, + ) -> HRESULT, + pub GetPropertyValue: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + *const PROPERTYKEY, + *mut PROPVARIANT, + ) -> HRESULT, + pub SetPropertyValue: unsafe extern "system" fn( + *mut core::ffi::c_void, + PCWSTR, + *const PROPERTYKEY, + *const PROPVARIANT, + ) -> HRESULT, + pub SetDefaultEndpoint: + unsafe extern "system" fn(*mut core::ffi::c_void, PCWSTR, ERole) -> HRESULT, + pub SetEndpointVisibility: + unsafe extern "system" fn(*mut core::ffi::c_void, PCWSTR, i32) -> HRESULT, +} + fn list_devices_for_flow( enumerator: &IMMDeviceEnumerator, flow: EDataFlow, diff --git a/src-tauri/src/commands/bindings.rs b/src-tauri/src/commands/bindings.rs index 17c83ef..51df8b5 100644 --- a/src-tauri/src/commands/bindings.rs +++ b/src-tauri/src/commands/bindings.rs @@ -319,6 +319,23 @@ fn apply_binding_action_internal( ); any_applied = true; } + ( + model::BindingAction::SetDefaultDevice, + model::BindingTarget::Device { device_id }, + ) => { + if let Err(err) = state.audio.set_default_device(device_id) { + run_logger::warn( + "bindings_cmd", + "apply_action_set_default_device_failed", + &format!( + "binding_id={} device_id={} error={}", + binding.id, device_id, err + ), + ); + } else { + any_applied = true; + } + } _ => {} } } @@ -767,7 +784,9 @@ pub fn apply_binding_action( let effective_action = action.unwrap_or_else(|| binding.action.clone()); if !matches!( effective_action, - model::BindingAction::Volume | model::BindingAction::ToggleMute + model::BindingAction::Volume + | model::BindingAction::ToggleMute + | model::BindingAction::SetDefaultDevice ) { run_logger::warn( "bindings_cmd", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b778230..a4a0e88 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -813,6 +813,45 @@ impl AppState { return Ok(()); } + if binding.action == model::BindingAction::SetDefaultDevice { + if event.value == 0 { + run_logger::debug( + "bindings", + "set_default_device_ignored_release", + &format!("binding_id={} action={:?}", binding.id, binding.action), + ); + return Ok(()); + } + + let mut any_applied = false; + for target in &targets { + if let model::BindingTarget::Device { device_id } = target { + if let Err(err) = self.audio.set_default_device(device_id) { + run_logger::error( + "bindings", + "set_default_device_failed", + &format!( + "binding_id={} device_id={} error={}", + binding.id, device_id, err + ), + ); + } else { + any_applied = true; + } + } + } + + if !any_applied { + run_logger::warn( + "bindings", + "set_default_device_no_target_applied", + &format!("binding_id={} targets={}", binding.id, targets.len()), + ); + } + + return Ok(()); + } + // Handle toggle mute action for button bindings if binding.action == model::BindingAction::ToggleMute { // Mark user activity to prevent stale feedback loop @@ -1246,6 +1285,7 @@ impl AppState { | model::BindingAction::MediaPrevTrack | model::BindingAction::MediaStop | model::BindingAction::Hotkey + | model::BindingAction::SetDefaultDevice ) { continue; } diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index 9574c38..50bafad 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -110,6 +110,7 @@ impl Default for RelativeFormat { pub enum BindingAction { Volume, ToggleMute, + SetDefaultDevice, MediaPlayPause, MediaNextTrack, MediaPrevTrack, @@ -616,4 +617,15 @@ mod tests { assert!(json.get("targets").is_some()); assert!(json.get("target").is_none()); } + + #[test] + fn deserialize_set_default_device_action() { + let mut json = binding_base_json(); + json.as_object_mut() + .unwrap() + .insert("action".to_string(), serde_json::json!("SetDefaultDevice")); + + let binding: Binding = serde_json::from_value(json).expect("binding should deserialize"); + assert_eq!(binding.action, BindingAction::SetDefaultDevice); + } } diff --git a/src/features/targets/targets.js b/src/features/targets/targets.js index a249f85..152a5cf 100644 --- a/src/features/targets/targets.js +++ b/src/features/targets/targets.js @@ -28,6 +28,8 @@ export function createTargetsFeature({ let activeTargetPanelSelect = null; let activeTargetPanelBack = null; const HOTKEY_ICON_DATA = "data:image/svg+xml;utf8,"; + const TOGGLE_MUTE_ICON_DATA = "data:image/svg+xml;utf8,"; + const SET_DEFAULT_DEVICE_ICON_DATA = "data:image/svg+xml;utf8,"; function mediaIconForAction(action) { if (action === "MediaNextTrack") return mediaNextTrackIconData; @@ -441,6 +443,7 @@ export function createTargetsFeature({ if (action === "MediaStop") return "Media Stop"; if (action === "Hotkey") return "Hotkey"; if (action === "ToggleMute") return "Toggle Mute"; + if (action === "SetDefaultDevice") return "Set Default"; if (action === "Volume" && isBindingButton) return "Trigger"; return action; }; @@ -481,7 +484,8 @@ export function createTargetsFeature({ renderLabelFromRawWithTags(label, { rawLabel: displayOption.label, extraTags: actionTags, - truncateMain: !isBindingButton, + truncateMain: true, + collapseTags: false, }); chip.appendChild(label); @@ -655,7 +659,28 @@ export function createTargetsFeature({ ]; } if (targetOption?.kind === "master" || targetOption?.kind === "focus") { - return [{ label: "Toggle Mute", value: "ToggleMute", kind: "action" }]; + return [{ + label: "Toggle Mute", + value: "ToggleMute", + kind: "action", + icon_data: TOGGLE_MUTE_ICON_DATA, + }]; + } + if (targetOption?.kind === "device") { + return [ + { + label: "Toggle Mute", + value: "ToggleMute", + kind: "action", + icon_data: TOGGLE_MUTE_ICON_DATA, + }, + { + label: "Set Default Device", + value: "SetDefaultDevice", + kind: "action", + icon_data: SET_DEFAULT_DEVICE_ICON_DATA, + }, + ]; } // Check per-target buttonActions first (set by plugins in getTargetOptions) diff --git a/src/features/ui/dropdown_badges.js b/src/features/ui/dropdown_badges.js index 3cc2346..e7e6616 100644 --- a/src/features/ui/dropdown_badges.js +++ b/src/features/ui/dropdown_badges.js @@ -52,7 +52,12 @@ export function renderLabelWithBadges( export function renderLabelFromRawWithTags( container, - { rawLabel = "", extraTags = [], truncateMain = true } = {}, + { + rawLabel = "", + extraTags = [], + truncateMain = true, + collapseTags = true, + } = {}, ) { if (!container) return; @@ -83,7 +88,7 @@ export function renderLabelFromRawWithTags( } let visibleTags = uniqueTags.map((tag) => ({ text: tag, kind: tagVariant(tag), hiddenTags: [] })); - if (truncateMain && uniqueTags.length > 1) { + if (truncateMain && collapseTags && uniqueTags.length > 1) { const baseLen = (base || rawLabel || "").length; const tagTextLen = uniqueTags.reduce((sum, t) => sum + String(t).length, 0); const shouldCollapse = uniqueTags.length > 2 || (baseLen + tagTextLen > 24); diff --git a/src/styles/bindings-and-panels.css b/src/styles/bindings-and-panels.css index a768a34..30c87f2 100644 --- a/src/styles/bindings-and-panels.css +++ b/src/styles/bindings-and-panels.css @@ -287,8 +287,6 @@ body.dark-mode .mode-chip .mode-chip-icon { .target-chip .target-label-main { font-size: 12px; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .target-chip-remove { @@ -375,11 +373,19 @@ body.dark-mode .target-chip-remove:hover { font-weight: 600; letter-spacing: 0.02em; white-space: nowrap; - max-width: 84px; + max-width: 160px; overflow: hidden; text-overflow: ellipsis; } +.binding-row .target-chip { + max-width: 100%; +} + +.binding-row .target-chip .target-tag { + max-width: 220px; +} + .target-tag--mix { background: #e8f3ff; border-color: #c8dcfb; From 5a9a4f9d0658614f75e54c24722f18b5087c8384 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:24:19 -0400 Subject: [PATCH 11/25] chore(release): bump version to 1.4.0 --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cffdcf9..2728560 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.3.0" +version = "1.4.0" edition = "2021" default-run = "midimaster" license = "MIT" From 440a467f1400932bcdf03929c864fadec1ac26af Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:50:24 -0400 Subject: [PATCH 12/25] feat(open-application): add target picker flow with persisted app icon --- src-tauri/Cargo.lock | 207 ++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/audio/windows.rs | 4 + src-tauri/src/bindings.rs | 1 + src-tauri/src/commands/settings.rs | 54 ++++++++ src-tauri/src/main.rs | 92 ++++++++++++- src-tauri/src/model.rs | 28 ++++ src/features/bindings/bindings.js | 53 ++++++++ src/features/targets/targets.js | 142 +++++++++++++++++++- src/main.js | 43 +++++- 10 files changed, 617 insertions(+), 8 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fbda38c..0ab0d2b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -78,6 +78,28 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.4", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -116,6 +138,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -145,6 +178,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -831,6 +875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", ] @@ -845,6 +891,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -868,6 +923,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -2086,7 +2147,7 @@ dependencies = [ [[package]] name = "midimaster" -version = "1.3.0" +version = "1.4.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -2097,6 +2158,7 @@ dependencies = [ "image", "midir", "rand 0.8.5", + "rfd", "serde", "serde_json", "sha2", @@ -2753,7 +2815,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2798,6 +2860,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2914,6 +2982,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.43" @@ -2954,6 +3031,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2974,6 +3061,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2992,6 +3089,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3120,6 +3226,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -3263,6 +3393,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4528,6 +4664,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -4708,6 +4850,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -5579,6 +5781,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.14", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2728560..cc09b7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,6 +47,7 @@ hex = "0.4" ed25519-dalek = { version = "2.1", features = ["rand_core"] } rand = "0.8" chrono = { version = "0.4", default-features = false, features = ["clock"] } +rfd = "0.15" [profile.release] panic = "abort" diff --git a/src-tauri/src/audio/windows.rs b/src-tauri/src/audio/windows.rs index 8bc38cd..db44aec 100644 --- a/src-tauri/src/audio/windows.rs +++ b/src-tauri/src/audio/windows.rs @@ -795,6 +795,10 @@ fn extract_icon_data(path: &str, index: i32) -> Option { icon_data } +pub fn extract_executable_icon_base64(path: &str) -> Option { + extract_icon_data(path, 0) +} + fn icon_to_png_base64(icon: HICON) -> Option { let mut icon_info = ICONINFO::default(); unsafe { GetIconInfo(icon, &mut icon_info).ok()? }; diff --git a/src-tauri/src/bindings.rs b/src-tauri/src/bindings.rs index 18e0ca8..a53f4b9 100644 --- a/src-tauri/src/bindings.rs +++ b/src-tauri/src/bindings.rs @@ -224,6 +224,7 @@ mod tests { assign_control: None, assign_mode: crate::model::AssignMode::Add, hotkey: None, + open_application: None, } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 7542264..cb9fb84 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -14,6 +14,13 @@ pub struct MonitorInfo { pub is_primary: bool, } +#[derive(Clone, Serialize)] +pub struct PickExecutableResult { + pub path: String, + pub display: String, + pub icon_data: Option, +} + #[tauri::command] pub fn list_monitors(app: AppHandle) -> Result, String> { let monitors = collect_monitor_descriptors(&app)?; @@ -297,3 +304,50 @@ pub fn open_logs_folder(app: AppHandle) -> Result { run_logger::info("settings", "open_logs_folder", &format!("path={}", path)); Ok(path) } + +#[tauri::command] +pub fn pick_executable_path() -> Result, String> { + #[cfg(target_os = "windows")] + { + let picked = rfd::FileDialog::new() + .add_filter("Applications", &["exe"]) + .pick_file(); + let Some(path) = picked else { + return Ok(None); + }; + + if !path.is_file() { + return Err("Selected path is not a file".to_string()); + } + + let ext_ok = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("exe")) + .unwrap_or(false); + if !ext_ok { + return Err("Selected file must be a .exe".to_string()); + } + + let path_string = path.to_string_lossy().to_string(); + let display = path + .file_stem() + .and_then(|name| name.to_str()) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| path_string.clone()); + + let icon_data = crate::audio::windows::extract_executable_icon_base64(&path_string); + + return Ok(Some(PickExecutableResult { + path: path_string, + display, + icon_data, + })); + } + + #[cfg(not(target_os = "windows"))] + { + Err("Open Application is currently supported only on Windows".to_string()) + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a4a0e88..fa4af07 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -33,6 +33,7 @@ use windows_autostart::set_windows_autostart; use profile_store::ProfileStore; use std::collections::HashMap; use std::path::Path; +use std::process::Command as ProcessCommand; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -813,6 +814,87 @@ impl AppState { return Ok(()); } + if binding.action == model::BindingAction::OpenApplication { + if event.value == 0 { + run_logger::debug( + "bindings", + "open_application_ignored_release", + &format!("binding_id={} action={:?}", binding.id, binding.action), + ); + return Ok(()); + } + + let Some(open_app) = binding.open_application.as_ref() else { + run_logger::warn( + "bindings", + "open_application_missing_config", + &format!("binding_id={}", binding.id), + ); + let _ = app.emit( + "binding_action_error", + serde_json::json!({ + "reason": "open_application_missing_config", + "binding_id": binding.id, + "title": "Open Application Not Configured", + "message": "Choose an executable for this binding's Open Application action.", + }), + ); + return Ok(()); + }; + + let app_path = open_app.path.trim(); + if app_path.is_empty() || !Path::new(app_path).is_file() { + run_logger::warn( + "bindings", + "open_application_path_missing", + &format!("binding_id={} path={}", binding.id, app_path), + ); + let app_name = open_app.display.trim(); + let display = if app_name.is_empty() { + app_path + } else { + app_name + }; + let _ = app.emit( + "binding_action_error", + serde_json::json!({ + "reason": "open_application_path_missing", + "binding_id": binding.id, + "title": "Application Not Found", + "message": format!("MIDIMaster couldn't find \"{}\". Re-select the .exe path in this binding.", display), + }), + ); + return Ok(()); + } + + match ProcessCommand::new(app_path).spawn() { + Ok(_) => { + run_logger::info( + "bindings", + "open_application_launched", + &format!("binding_id={} path={}", binding.id, app_path), + ); + } + Err(err) => { + run_logger::error( + "bindings", + "open_application_launch_failed", + &format!("binding_id={} path={} error={}", binding.id, app_path, err), + ); + let _ = app.emit( + "binding_action_error", + serde_json::json!({ + "reason": "open_application_launch_failed", + "binding_id": binding.id, + "title": "Launch Failed", + "message": format!("MIDIMaster couldn't open this application: {}", err), + }), + ); + } + } + return Ok(()); + } + if binding.action == model::BindingAction::SetDefaultDevice { if event.value == 0 { run_logger::debug( @@ -996,7 +1078,8 @@ impl AppState { } model::BindingTarget::Unset | model::BindingTarget::MediaControl - | model::BindingTarget::Hotkey => {} + | model::BindingTarget::Hotkey + | model::BindingTarget::OpenApplication => {} } } @@ -1167,7 +1250,8 @@ impl AppState { } model::BindingTarget::Unset | model::BindingTarget::MediaControl - | model::BindingTarget::Hotkey => {} + | model::BindingTarget::Hotkey + | model::BindingTarget::OpenApplication => {} } } @@ -1285,6 +1369,7 @@ impl AppState { | model::BindingAction::MediaPrevTrack | model::BindingAction::MediaStop | model::BindingAction::Hotkey + | model::BindingAction::OpenApplication | model::BindingAction::SetDefaultDevice ) { continue; @@ -1348,6 +1433,7 @@ impl AppState { model::BindingTarget::Unset => None, model::BindingTarget::MediaControl => None, model::BindingTarget::Hotkey => None, + model::BindingTarget::OpenApplication => None, model::BindingTarget::Integration { .. } => None, } } else { @@ -1417,6 +1503,7 @@ impl AppState { model::BindingTarget::Unset => None, model::BindingTarget::MediaControl => None, model::BindingTarget::Hotkey => None, + model::BindingTarget::OpenApplication => None, model::BindingTarget::Integration { .. } => None, } }; @@ -1918,6 +2005,7 @@ fn main() { set_active_profile_preference, reset_app_data, open_logs_folder, + pick_executable_path, list_playback_devices, list_recording_devices, set_master_volume, diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index 50bafad..2067896 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -111,6 +111,7 @@ pub enum BindingAction { Volume, ToggleMute, SetDefaultDevice, + OpenApplication, MediaPlayPause, MediaNextTrack, MediaPrevTrack, @@ -132,6 +133,16 @@ pub struct HotkeyMapping { pub display: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OpenApplicationMapping { + #[serde(default)] + pub path: String, + #[serde(default)] + pub display: String, + #[serde(default)] + pub icon_data: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum AssignMode { Add, @@ -173,6 +184,7 @@ pub enum BindingTarget { }, MediaControl, Hotkey, + OpenApplication, Unset, } @@ -205,6 +217,7 @@ fn binding_target_from_value(v: serde_json::Value) -> Result Ok(BindingTarget::Focus), "MediaControl" => Ok(BindingTarget::MediaControl), "Hotkey" => Ok(BindingTarget::Hotkey), + "OpenApplication" => Ok(BindingTarget::OpenApplication), "Unset" => Ok(BindingTarget::Unset), other => Err(format!("Unknown BindingTarget string: {}", other)), }; @@ -273,6 +286,7 @@ fn binding_target_from_value(v: serde_json::Value) -> Result Ok(BindingTarget::Unset), "MediaControl" => Ok(BindingTarget::MediaControl), "Hotkey" => Ok(BindingTarget::Hotkey), + "OpenApplication" => Ok(BindingTarget::OpenApplication), // New generic integration target "Integration" => { @@ -417,6 +431,8 @@ pub struct Binding { pub assign_mode: AssignMode, #[serde(default)] pub hotkey: Option, + #[serde(default)] + pub open_application: Option, } impl Binding { @@ -611,6 +627,7 @@ mod tests { assign_control: None, assign_mode: AssignMode::Add, hotkey: None, + open_application: None, }; let json = serde_json::to_value(binding).expect("binding should serialize"); @@ -628,4 +645,15 @@ mod tests { let binding: Binding = serde_json::from_value(json).expect("binding should deserialize"); assert_eq!(binding.action, BindingAction::SetDefaultDevice); } + + #[test] + fn deserialize_open_application_action() { + let mut json = binding_base_json(); + json.as_object_mut() + .unwrap() + .insert("action".to_string(), serde_json::json!("OpenApplication")); + + let binding: Binding = serde_json::from_value(json).expect("binding should deserialize"); + assert_eq!(binding.action, BindingAction::OpenApplication); + } } diff --git a/src/features/bindings/bindings.js b/src/features/bindings/bindings.js index 6ddbccb..989cb55 100644 --- a/src/features/bindings/bindings.js +++ b/src/features/bindings/bindings.js @@ -148,6 +148,10 @@ export function createBindingsFeature({ return target === "Hotkey"; } + function isOpenApplicationTarget(target) { + return target === "OpenApplication"; + } + function getTargets(binding) { if (!binding || typeof binding !== "object") return []; if (Array.isArray(binding.targets) && binding.targets.length > 0) { @@ -294,6 +298,21 @@ export function createBindingsFeature({ return { keys, display }; } + function normalizeOpenApplicationMapping(rawOpenApplication) { + if (!rawOpenApplication || typeof rawOpenApplication !== "object") return null; + const path = String(rawOpenApplication.path || "").trim(); + const display = String(rawOpenApplication.display || "").trim(); + const icon_data = typeof rawOpenApplication.icon_data === "string" && rawOpenApplication.icon_data.trim() + ? rawOpenApplication.icon_data.trim() + : null; + if (!path) return null; + return { + path, + display: display || path, + icon_data, + }; + } + function normalizeHotkeyKey(event) { const key = String(event?.key || "").trim(); if (!key) return null; @@ -742,6 +761,7 @@ export function createBindingsFeature({ ensureBindingShape(binding); setTargets(binding, getTargets(binding)); binding.hotkey = normalizeHotkeyMapping(binding.hotkey); + binding.open_application = normalizeOpenApplicationMapping(binding.open_application); const item = document.createElement("div"); item.className = "list-item binding-item"; @@ -949,21 +969,27 @@ export function createBindingsFeature({ isButton, binding.action, binding.hotkey?.display || "", + binding.open_application, ); targetSelect.addEventListener("change", async () => { const previousTargets = getTargets(binding); const previousHadHotkeyTarget = previousTargets.some(isHotkeyTarget); + const previousHadOpenApplicationTarget = previousTargets.some(isOpenApplicationTarget); const selectedTargets = Array.isArray(targetSelect.__selectedTargets) ? targetSelect.__selectedTargets : (targetSelect.__selectedTarget ? [targetSelect.__selectedTarget] : []); setTargets(binding, selectedTargets); const hasHotkeyTarget = selectedTargets.some(isHotkeyTarget); + const hasOpenApplicationTarget = selectedTargets.some(isOpenApplicationTarget); const previousAction = binding.action; const previousHotkey = normalizeHotkeyMapping(binding.hotkey); + const previousOpenApplication = normalizeOpenApplicationMapping(binding.open_application); if (isButton) { binding.action = hasHotkeyTarget ? "Hotkey" + : hasOpenApplicationTarget + ? "OpenApplication" : (targetSelect.dataset.action || binding.action || "ToggleMute"); } else { binding.action = "Volume"; @@ -977,12 +1003,20 @@ export function createBindingsFeature({ } } + if (isButton && !hasOpenApplicationTarget && previousHadOpenApplicationTarget) { + binding.open_application = null; + if (binding.action === "OpenApplication") { + binding.action = targetSelect.dataset.action || "ToggleMute"; + } + } + if (isButton && hasHotkeyTarget && !previousHadHotkeyTarget) { const learnedHotkey = await startHotkeyLearn(binding); if (!learnedHotkey) { setTargets(binding, previousTargets); binding.action = previousAction || "ToggleMute"; binding.hotkey = previousHotkey; + binding.open_application = previousOpenApplication; await invoke("add_binding", { binding }); await saveProfile(); renderBindings(); @@ -992,6 +1026,25 @@ export function createBindingsFeature({ targetSelect?.setHotkeyDisplay?.(binding.hotkey?.display || ""); } + if (isButton && binding.action === "OpenApplication") { + binding.open_application = normalizeOpenApplicationMapping( + targetSelect?.getOpenApplication?.() || targetSelect?.__openApplication, + ); + } else { + binding.open_application = null; + } + + if (isButton && !hasHotkeyTarget && !hasOpenApplicationTarget && binding.action === "OpenApplication" && !binding.open_application) { + setTargets(binding, previousTargets); + binding.action = previousAction || "ToggleMute"; + binding.hotkey = previousHotkey; + binding.open_application = previousOpenApplication; + await invoke("add_binding", { binding }); + await saveProfile(); + renderBindings(); + return; + } + if (!isButton) { const primaryTarget = getPrimaryTarget(binding); const newVolume = (bindingLastValues[binding.id] != null) diff --git a/src/features/targets/targets.js b/src/features/targets/targets.js index 152a5cf..ab0522a 100644 --- a/src/features/targets/targets.js +++ b/src/features/targets/targets.js @@ -1,6 +1,7 @@ import { closeOpenDropdowns, renderLabelFromRawWithTags } from "../ui/dropdown_badges.js"; export function createTargetsFeature({ + invoke, dom, masterIconData, focusIconData, @@ -24,13 +25,89 @@ export function createTargetsFeature({ const normalizeKey = (typeof normalizeSessionKey === "function") ? normalizeSessionKey : (() => ""); const targetKey = (typeof integrationTargetKey === "function") ? integrationTargetKey : (() => ""); const resolveDisplay = (typeof resolveOsdTarget === "function") ? resolveOsdTarget : (() => null); + const callInvoke = (typeof invoke === "function") ? invoke : null; let activeTargetPanelSelect = null; let activeTargetPanelBack = null; const HOTKEY_ICON_DATA = "data:image/svg+xml;utf8,"; + const OPEN_APPLICATION_TARGET_ICON_DATA = "data:image/svg+xml;utf8,"; const TOGGLE_MUTE_ICON_DATA = "data:image/svg+xml;utf8,"; const SET_DEFAULT_DEVICE_ICON_DATA = "data:image/svg+xml;utf8,"; + function normalizeOpenApplication(raw) { + if (!raw || typeof raw !== "object") return null; + const path = String(raw.path || "").trim(); + const display = String(raw.display || "").trim(); + const iconData = typeof raw.icon_data === "string" && raw.icon_data.trim() + ? raw.icon_data.trim() + : null; + if (!path) return null; + return { + path, + display: friendlyAppName(display || path) || display || path, + icon_data: iconData, + }; + } + + function displayNameFromPath(path) { + const value = String(path || "").trim(); + if (!value) return ""; + const parts = value.split(/[\\/]/); + return parts[parts.length - 1] || value; + } + + function friendlyAppName(rawNameOrPath) { + const base = displayNameFromPath(rawNameOrPath); + if (!base) return ""; + return base.replace(/\.exe$/i, "").trim() || base; + } + + function normalizeCompareName(raw) { + return String(raw || "") + .toLowerCase() + .replace(/\.exe$/i, "") + .replace(/[^a-z0-9]+/g, ""); + } + + function resolveOpenApplicationIcon(openApplication) { + if (!openApplication?.display && !openApplication?.path) return null; + const needle = normalizeCompareName(openApplication.display || openApplication.path); + if (!needle) return null; + const sessions = getSess(); + if (!Array.isArray(sessions) || sessions.length === 0) return null; + for (const session of sessions) { + const icon = session?.icon_data || null; + if (!icon) continue; + const candidates = [ + session?.display_name, + session?.name, + session?.process_name, + session?.process, + session?.exe, + ]; + const matched = candidates.some((candidate) => normalizeCompareName(candidate) === needle); + if (matched) return icon; + } + return null; + } + + async function pickOpenApplication() { + if (!callInvoke) return null; + const picked = await callInvoke("pick_executable_path"); + if (!picked) return null; + const path = String(picked.path || "").trim(); + if (!path) return null; + const display = String(picked.display || "").trim(); + const iconData = typeof picked.icon_data === "string" && picked.icon_data.trim() + ? picked.icon_data.trim() + : null; + return { + path, + display: friendlyAppName(display || path), + icon_data: iconData, + }; + } + function mediaIconForAction(action) { if (action === "MediaNextTrack") return mediaNextTrackIconData; if (action === "MediaPrevTrack") return mediaPrevTrackIconData; @@ -180,6 +257,7 @@ export function createTargetsFeature({ : (currentTarget === "Focus" || currentTarget?.Focus != null) ? "focus" : currentTarget === "MediaControl" ? "media-control" : currentTarget === "Hotkey" ? "hotkey-target" + : currentTarget === "OpenApplication" ? "open-application-target" : "placeholder" ); @@ -187,7 +265,7 @@ export function createTargetsFeature({ if (selectedKind === "integration-target") selectedValue = targetKey(integration); else if (selectedKind === "session") selectedValue = selectedAppName || selectedSessionKey || ""; else if (selectedKind === "device") selectedValue = selectedDeviceId || ""; - else if (selectedKind === "master" || selectedKind === "focus" || selectedKind === "media-control" || selectedKind === "hotkey-target") selectedValue = selectedKind; + else if (selectedKind === "master" || selectedKind === "focus" || selectedKind === "media-control" || selectedKind === "hotkey-target" || selectedKind === "open-application-target") selectedValue = selectedKind; else if (selectedKind === "placeholder") selectedValue = "placeholder"; const options = [ @@ -218,6 +296,12 @@ export function createTargetsFeature({ icon_data: HOTKEY_ICON_DATA, kind: "hotkey-target", }); + options.push({ + value: "open-application-target", + label: "Open Application", + icon_data: OPEN_APPLICATION_TARGET_ICON_DATA, + kind: "open-application-target", + }); } if (pluginHost) { @@ -355,7 +439,13 @@ export function createTargetsFeature({ return { options, selectedValue, selectedKind, activeIntegrationOption }; } - function buildTargetSelect(currentTarget, isBindingButton = false, currentAction = "Volume", currentHotkeyDisplay = "") { + function buildTargetSelect( + currentTarget, + isBindingButton = false, + currentAction = "Volume", + currentHotkeyDisplay = "", + currentOpenApplication = null, + ) { const container = document.createElement("div"); container.className = "target-dropdown binding-target-dropdown"; @@ -389,6 +479,7 @@ export function createTargetsFeature({ if (target === "Focus" || target?.Focus != null) return "focus"; if (target === "MediaControl") return "media-control"; if (target === "Hotkey") return "hotkey-target"; + if (target === "OpenApplication") return "open-application-target"; const integration = target?.Integration || target?.integration; if (integration) { return `integration:${targetKey(integration)}`; @@ -410,6 +501,9 @@ export function createTargetsFeature({ let hotkeyDisplay = String(currentHotkeyDisplay || ""); const targetDisplayCache = new Map(); let selectedAction = isBindingButton ? (currentAction || "ToggleMute") : "Volume"; + let selectedOpenApplication = isBindingButton + ? normalizeOpenApplication(currentOpenApplication) + : null; const { options, selectedValue, selectedKind, activeIntegrationOption } = buildTargetOptions(selectedTargets[0] || currentTarget, isBindingButton); const placeholderOption = { @@ -444,6 +538,7 @@ export function createTargetsFeature({ if (action === "Hotkey") return "Hotkey"; if (action === "ToggleMute") return "Toggle Mute"; if (action === "SetDefaultDevice") return "Set Default"; + if (action === "OpenApplication") return "Open Application"; if (action === "Volume" && isBindingButton) return "Trigger"; return action; }; @@ -457,6 +552,16 @@ export function createTargetsFeature({ icon_data: cached?.icon_data ?? HOTKEY_ICON_DATA, }; } + if (target === "OpenApplication") { + const openAppLabel = friendlyAppName(selectedOpenApplication?.display || selectedOpenApplication?.path || "") || "Open Application"; + return { + label: openAppLabel, + icon_data: selectedOpenApplication?.icon_data + || resolveOpenApplicationIcon(selectedOpenApplication) + || cached?.icon_data + || OPEN_APPLICATION_TARGET_ICON_DATA, + }; + } const resolved = resolveDisplay(target); const merged = { label: (resolved?.label || cached?.label || "Target"), @@ -564,6 +669,9 @@ export function createTargetsFeature({ if (option.kind === "hotkey-target") { return "Hotkey"; } + if (option.kind === "open-application-target") { + return "OpenApplication"; + } if (option.kind === "device") { return { Device: { device_id: option.value } }; } @@ -579,6 +687,7 @@ export function createTargetsFeature({ const syncContainerValue = (markUnavailable = false) => { container.__selectedTargets = [...selectedTargets]; container.__selectedTarget = selectedTargets[0] || "Unset"; + container.__openApplication = selectedOpenApplication; container.value = selectedTargets.length ? targetIdentity(selectedTargets[0]) : ""; container.dataset.kind = selectedTargets.length ? "multi" : "placeholder"; container.classList.toggle("target-unavailable", Boolean(markUnavailable)); @@ -598,6 +707,15 @@ export function createTargetsFeature({ if (nextActionValue) { selectedAction = nextActionValue; } + if (nextActionValue !== "OpenApplication") { + selectedOpenApplication = null; + } + const chosenOpenApplication = normalizeOpenApplication( + actionChoice?.openApplication || actionChoice?.open_application, + ); + if (chosenOpenApplication) { + selectedOpenApplication = chosenOpenApplication; + } if (nextActionLabel && option && typeof option === "object") { option.__selectedActionLabel = nextActionLabel; } @@ -609,6 +727,9 @@ export function createTargetsFeature({ if (mapped === "Hotkey") { selectedAction = "Hotkey"; } + if (mapped === "OpenApplication") { + selectedAction = "OpenApplication"; + } const key = targetIdentity(mapped); const cachedLabel = String(option?.label || "").trim(); if (cachedLabel || option?.icon_data) { @@ -726,6 +847,22 @@ export function createTargetsFeature({ return false; } + if (isBindingButton && targetOption.kind === "open-application-target") { + (async () => { + try { + const openApplication = await pickOpenApplication(); + if (!openApplication) return; + selectOption(targetOption, { + value: "OpenApplication", + label: "Open Application", + openApplication, + }); + closeTargetPanel(); + } catch { } + })(); + return false; + } + if (isBindingButton && targetOption.kind !== "hotkey-target") { const actionOptions = buildButtonActionOptions(targetOption); setTimeout(() => { @@ -844,6 +981,7 @@ export function createTargetsFeature({ hotkeyDisplay = String(nextDisplay || ""); setDisplay(); }; + container.getOpenApplication = () => selectedOpenApplication; return container; } diff --git a/src/main.js b/src/main.js index 534a695..3aa50e8 100644 --- a/src/main.js +++ b/src/main.js @@ -219,6 +219,16 @@ function normalizeBinding(binding) { out.relative_format = "Auto"; if (out.assign_mode !== "Replace") out.assign_mode = "Add"; if (!out.hotkey || typeof out.hotkey !== "object") out.hotkey = null; + if (!out.open_application || typeof out.open_application !== "object") { + out.open_application = null; + } else { + const path = String(out.open_application.path || "").trim(); + const display = String(out.open_application.display || "").trim(); + const icon_data = typeof out.open_application.icon_data === "string" && out.open_application.icon_data.trim() + ? out.open_application.icon_data.trim() + : null; + out.open_application = path ? { path, display: display || path, icon_data } : null; + } return out; } @@ -892,6 +902,7 @@ profilesFeature = createProfilesFeature({ profilesFeature.bindUi(); targetsFeature = createTargetsFeature({ + invoke, dom: { targetPanel, targetPanelList, @@ -1380,8 +1391,20 @@ function buildTargetOptions(currentTarget, isButton = false) { return targetsFeature?.buildTargetOptions?.(currentTarget, isButton); } -function buildTargetSelect(currentTarget, isBindingButton = false, currentAction = "Volume", currentHotkeyDisplay = "") { - return targetsFeature?.buildTargetSelect?.(currentTarget, isBindingButton, currentAction, currentHotkeyDisplay); +function buildTargetSelect( + currentTarget, + isBindingButton = false, + currentAction = "Volume", + currentHotkeyDisplay = "", + currentOpenApplication = null, +) { + return targetsFeature?.buildTargetSelect?.( + currentTarget, + isBindingButton, + currentAction, + currentHotkeyDisplay, + currentOpenApplication, + ); } const LEARN_PANEL_DEFAULT_TITLE = "Waiting for MIDI Input"; @@ -1509,6 +1532,7 @@ function createBindingFromLearn(payload) { assign_control: null, assign_mode: "Add", hotkey: null, + open_application: null, }; } @@ -1601,6 +1625,21 @@ async function setupListeners() { } }); + await listen("binding_action_error", (event) => { + let payload = event.payload; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + payload = null; + } + } + if (!payload || typeof payload !== "object") return; + const title = String(payload.title || "Action Failed").trim() || "Action Failed"; + const message = String(payload.message || "").trim() || "MIDIMaster could not complete this action."; + showAlert(title, message); + }); + await listen("binding_aux_assign_update", (event) => { let payload = event.payload; if (typeof payload === "string") { From bad2daafff8f028f9f64f3f03619a50d09955b6a Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:50:51 -0400 Subject: [PATCH 13/25] chore(release): bump version to 1.5.0 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0ab0d2b..0c8101f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2147,7 +2147,7 @@ dependencies = [ [[package]] name = "midimaster" -version = "1.4.0" +version = "1.5.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc09b7f..93b8c3c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.4.0" +version = "1.5.0" edition = "2021" default-run = "midimaster" license = "MIT" From c9e9dcf61b4cb7b638bb1efe9f3210ef0f8b6c8c Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:03:17 -0400 Subject: [PATCH 14/25] feat: add built-in updater via GitHub releases --- .github/workflows/release.yml | 59 ++++++- README.md | 21 ++- src-tauri/Cargo.lock | 262 ++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 3 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/updates.rs | 199 ++++++++++++++++++++++ src-tauri/src/main.rs | 3 + src-tauri/tauri.conf.json | 12 ++ src/features/settings/settings.js | 168 ++++++++++++++++++ src/index.html | 13 +- src/main.js | 28 +++ src/styles/base.css | 18 ++ src/styles/bindings-and-panels.css | 71 +++++++- 13 files changed, 843 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/commands/updates.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d970a1..c71c995 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,11 +42,61 @@ jobs: - name: Install Windows bundler tools shell: pwsh run: | - choco install nsis wixtoolset --no-progress -y + choco install nsis --no-progress -y + + - name: Validate updater signing secrets + shell: pwsh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace("${env:TAURI_SIGNING_PRIVATE_KEY}")) { + throw "Missing TAURI_SIGNING_PRIVATE_KEY secret. Configure updater signing secrets before releasing." + } - name: Build (Tauri) working-directory: src-tauri - run: cargo tauri build --bundles nsis,msi + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: cargo tauri build --bundles nsis + + - name: Generate updater metadata (latest.json) + shell: pwsh + run: | + $tag = "${env:GITHUB_REF_NAME}" + $repo = "${env:GITHUB_REPOSITORY}" + + $nsisDir = "src-tauri/target/release/bundle/nsis" + $setupExe = Get-ChildItem -Path $nsisDir -Filter *.exe | + Where-Object { $_.Name -like "*setup*.exe" } | + Select-Object -First 1 + + if (-not $setupExe) { + throw "Could not find NSIS setup executable in $nsisDir" + } + + $sigPath = "$($setupExe.FullName).sig" + if (-not (Test-Path $sigPath)) { + throw "Missing signature file for updater artifact: $sigPath" + } + + $signature = (Get-Content -Raw $sigPath).Trim() + $downloadUrl = "https://github.com/$repo/releases/download/$tag/$($setupExe.Name)" + + $latest = @{ + version = $tag + notes = "See release notes on GitHub." + pub_date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + platforms = @{ + "windows-x86_64" = @{ + signature = $signature + url = $downloadUrl + } + } + } + + $latestJsonPath = "src-tauri/target/release/bundle/latest.json" + $latest | ConvertTo-Json -Depth 8 | Out-File -FilePath $latestJsonPath -Encoding utf8 - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -59,5 +109,6 @@ jobs: fail_on_unmatched_files: true files: | src-tauri/target/release/midimaster.exe - src-tauri/target/release/bundle/**/*.exe - src-tauri/target/release/bundle/**/*.msi + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/nsis/*.exe.sig + src-tauri/target/release/bundle/latest.json diff --git a/README.md b/README.md index 8bcf396..58d264e 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,30 @@ cargo tauri dev Releases are created from git tags. +One-time updater key setup (required for built-in updates): + +1. Generate a signing keypair locally: + ```powershell + cargo tauri signer generate -w "$HOME/.tauri/midimaster.key" + ``` +2. Copy the generated public key into `src-tauri/tauri.conf.json` at `plugins.updater.pubkey`. +3. Add these GitHub repo secrets for the release workflow: + - `TAURI_SIGNING_PRIVATE_KEY` (contents of the private key file, or a secure path) + - `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (empty string if no password) + +Release flow: + 1. Update `src-tauri/Cargo.toml` version. 2. Create and push a tag in the form `v` (example: `v0.1.0`). Pushing the tag triggers the GitHub Actions release workflow, which builds the Windows bundle and -attaches the installer artifacts to a GitHub Release. +publishes NSIS installer artifacts, signatures, and `latest.json` to GitHub Releases. + +Built-in updater behavior: + +- Update checks use GitHub Releases only (`releases/latest/download/latest.json`). +- Stable releases only are offered. +- If in-app update fails, users can still install manually from the GitHub release page. Documentation diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0c8101f..cf7e6d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -516,6 +516,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.43" @@ -1116,6 +1122,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.7" @@ -1405,8 +1422,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1416,9 +1435,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1677,6 +1698,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.5", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -2064,6 +2101,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.7.4", ] [[package]] @@ -2093,6 +2131,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2147,7 +2191,7 @@ dependencies = [ [[package]] name = "midimaster" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -2165,6 +2209,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-single-instance", + "tauri-plugin-updater", "tauri-plugin-window-state", "thiserror 1.0.69", "tokio", @@ -2173,7 +2218,7 @@ dependencies = [ "url", "uuid", "windows 0.61.3", - "zip", + "zip 2.4.2", ] [[package]] @@ -2198,6 +2243,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2501,6 +2552,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2574,6 +2637,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "pango" version = "0.18.3" @@ -2623,7 +2700,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2991,6 +3068,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.43" @@ -3131,6 +3263,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3205,16 +3346,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3224,6 +3370,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.5", ] [[package]] @@ -3264,6 +3411,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3307,6 +3460,7 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ + "web-time", "zeroize", ] @@ -3705,7 +3859,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3908,6 +4062,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4060,6 +4225,38 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.17", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" @@ -4281,6 +4478,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -4307,6 +4519,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -4920,6 +5142,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "2.0.1" @@ -5573,6 +5805,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" @@ -5754,6 +5996,18 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.14" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 93b8c3c..088d992 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.5.0" +version = "1.5.1" edition = "2021" default-run = "midimaster" license = "MIT" @@ -12,6 +12,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["tray-icon", "devtools"] } +tauri-plugin-updater = "2" tauri-plugin-window-state = "2.0.0" tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0f4eea7..e61e475 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,9 +3,11 @@ pub mod bindings; pub mod midi; pub mod profiles; pub mod settings; +pub mod updates; pub use audio::*; pub use bindings::*; pub use midi::*; pub use profiles::*; pub use settings::*; +pub use updates::*; diff --git a/src-tauri/src/commands/updates.rs b/src-tauri/src/commands/updates.rs new file mode 100644 index 0000000..e102105 --- /dev/null +++ b/src-tauri/src/commands/updates.rs @@ -0,0 +1,199 @@ +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_updater::UpdaterExt; + +#[derive(Clone, Serialize)] +pub struct UpdateInfo { + pub available: bool, + pub current_version: String, + pub version: Option, + pub body: Option, + pub date: Option, +} + +#[derive(Clone, Serialize)] +struct UpdateStatusEvent { + phase: String, + message: Option, + current_version: Option, + version: Option, + downloaded: Option, + content_length: Option, +} + +fn emit_status(app: &AppHandle, event: UpdateStatusEvent) { + let _ = app.emit("updater_status", event); +} + +fn emit_failed(app: &AppHandle, message: String) { + emit_status( + app, + UpdateStatusEvent { + phase: "failed".to_string(), + message: Some(message), + current_version: None, + version: None, + downloaded: None, + content_length: None, + }, + ); +} + +#[tauri::command] +pub async fn check_for_updates(app: AppHandle) -> Result { + let current_version = app.package_info().version.to_string(); + emit_status( + &app, + UpdateStatusEvent { + phase: "checking".to_string(), + message: None, + current_version: Some(current_version.clone()), + version: None, + downloaded: None, + content_length: None, + }, + ); + + let updater = app + .updater_builder() + .build() + .map_err(|err| format!("Unable to initialize updater: {err}"))?; + + let update = updater + .check() + .await + .map_err(|err| format!("Update check failed: {err}"))?; + + let Some(update) = update else { + emit_status( + &app, + UpdateStatusEvent { + phase: "no_update".to_string(), + message: Some("No update available.".to_string()), + current_version: Some(current_version.clone()), + version: None, + downloaded: None, + content_length: None, + }, + ); + return Ok(UpdateInfo { + available: false, + current_version, + version: None, + body: None, + date: None, + }); + }; + + let latest_version = update.version.clone(); + let date = update.date.map(|value| value.to_string()); + emit_status( + &app, + UpdateStatusEvent { + phase: "available".to_string(), + message: Some("Update available.".to_string()), + current_version: Some(current_version.clone()), + version: Some(latest_version.clone()), + downloaded: None, + content_length: None, + }, + ); + Ok(UpdateInfo { + available: true, + current_version, + version: Some(latest_version), + body: update.body.clone(), + date, + }) +} + +#[tauri::command] +pub async fn download_and_install_update(app: AppHandle) -> Result<(), String> { + let updater = match app.updater_builder().build() { + Ok(updater) => updater, + Err(err) => { + let message = format!("Unable to initialize updater: {err}"); + emit_failed(&app, message.clone()); + return Err(message); + } + }; + + let update = match updater.check().await { + Ok(update) => update, + Err(err) => { + let message = format!("Update check failed: {err}"); + emit_failed(&app, message.clone()); + return Err(message); + } + }; + + let Some(update) = update else { + return Err("No update available to install.".to_string()); + }; + + let version = update.version.clone(); + emit_status( + &app, + UpdateStatusEvent { + phase: "downloading".to_string(), + message: Some("Downloading update...".to_string()), + current_version: Some(update.current_version.clone()), + version: Some(version.clone()), + downloaded: Some(0), + content_length: None, + }, + ); + + let mut downloaded_total: u64 = 0; + let install_result = update + .download_and_install( + |chunk_length, content_length| { + downloaded_total = downloaded_total.saturating_add(chunk_length as u64); + emit_status( + &app, + UpdateStatusEvent { + phase: "downloading".to_string(), + message: None, + current_version: None, + version: Some(version.clone()), + downloaded: Some(downloaded_total), + content_length, + }, + ); + }, + || { + emit_status( + &app, + UpdateStatusEvent { + phase: "downloaded".to_string(), + message: Some("Update downloaded. Installing...".to_string()), + current_version: None, + version: Some(version.clone()), + downloaded: None, + content_length: None, + }, + ); + }, + ) + .await; + + if let Err(err) = install_result { + let message = format!("Failed to download/install update: {err}"); + emit_failed(&app, message.clone()); + return Err(message); + } + + emit_status( + &app, + UpdateStatusEvent { + phase: "installed".to_string(), + message: Some("Update installed. Restarting app...".to_string()), + current_version: None, + version: Some(version), + downloaded: None, + content_length: None, + }, + ); + + app.restart(); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fa4af07..c9ecceb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1664,6 +1664,7 @@ mod tests { fn main() { tauri::Builder::default() + .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); @@ -2045,6 +2046,8 @@ fn main() { get_wavelink_ws_port, fetch_store_catalog, install_store_plugin, + check_for_updates, + download_and_install_update, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1c92c45..1a8f922 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,6 +25,18 @@ } }, "bundle": { + "createUpdaterArtifacts": true, "icon": ["icons/MIDIMaster.png", "icons/icon.ico"] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENDMjhDRTRENDZFOTRDMEIKUldRTFRPbEdUYzRvek5vSHVhaTR1d3ZRTytoM3pwNjhnbUs5bHVDZVhua2JNTFZRdHV1dDUvOUgK", + "endpoints": [ + "https://github.com/prgmitchell/MIDIMaster/releases/latest/download/latest.json" + ], + "windows": { + "installMode": "passive" + } + } } } diff --git a/src/features/settings/settings.js b/src/features/settings/settings.js index 84d411e..4160f97 100644 --- a/src/features/settings/settings.js +++ b/src/features/settings/settings.js @@ -9,6 +9,7 @@ import { export function createSettingsFeature({ invoke, + listen, dom, getOsdSettings, setOsdSettings, @@ -27,6 +28,158 @@ export function createSettingsFeature({ let monitorDocClickBound = false; let settingsDocClickBound = false; const settingsSelectDropdowns = new Map(); + let updaterUnlisten = null; + const updateState = { + currentVersion: "-", + latestVersion: "-", + available: false, + checking: false, + downloading: false, + }; + + function formatUpdaterError(error) { + const message = String(error || "Update check failed."); + const normalized = message.toLowerCase(); + if ( + normalized.includes("valid release json") + || normalized.includes("latest.json") + || normalized.includes("404") + ) { + return "Updater metadata is not published yet for this release. Use GitHub Releases for manual install."; + } + if (normalized.includes("network") || normalized.includes("timeout")) { + return "Unable to reach update server right now. Try again in a moment."; + } + return message; + } + + function setUpdateStatus(message, kind = "") { + if (!d.settingsUpdateStatus) return; + d.settingsUpdateStatus.textContent = String(message || ""); + d.settingsUpdateStatus.classList.remove("error", "success"); + if (kind === "error" || kind === "success") { + d.settingsUpdateStatus.classList.add(kind); + } + } + + function renderUpdateUi() { + if (d.updateCurrentVersion) { + d.updateCurrentVersion.textContent = updateState.currentVersion || "-"; + } + if (d.updateLatestVersion) { + d.updateLatestVersion.textContent = updateState.latestVersion || "-"; + } + if (d.checkForUpdatesButton) { + d.checkForUpdatesButton.disabled = updateState.checking || updateState.downloading; + } + if (d.installUpdateButton) { + d.installUpdateButton.classList.toggle("hidden", !updateState.available); + d.installUpdateButton.disabled = !updateState.available || updateState.checking || updateState.downloading; + } + } + + function normalizeUpdateInfo(updateInfo) { + const info = (updateInfo && typeof updateInfo === "object") ? updateInfo : {}; + const available = Boolean(info.available); + const currentVersion = String(info.current_version ?? info.currentVersion ?? updateState.currentVersion ?? "-"); + const latestVersionRaw = info.version ?? null; + const latestVersion = latestVersionRaw ? String(latestVersionRaw) : "-"; + const body = info.body ? String(info.body) : ""; + return { available, currentVersion, latestVersion, body }; + } + + async function checkForUpdates({ silent = false } = {}) { + updateState.checking = true; + renderUpdateUi(); + if (!silent) { + setUpdateStatus("Checking for updates..."); + } + try { + const updateInfo = await invoke("check_for_updates"); + const normalized = normalizeUpdateInfo(updateInfo); + updateState.currentVersion = normalized.currentVersion; + updateState.latestVersion = normalized.latestVersion; + updateState.available = normalized.available; + if (normalized.available) { + const suffix = normalized.body ? " (release notes available)" : ""; + setUpdateStatus(`Update available: ${normalized.latestVersion}${suffix}`, "success"); + } else { + setUpdateStatus("You are up to date.", "success"); + } + return normalized; + } catch (error) { + if (!silent) { + setUpdateStatus(formatUpdaterError(error), "error"); + } + return null; + } finally { + updateState.checking = false; + renderUpdateUi(); + } + } + + async function installAvailableUpdate() { + updateState.downloading = true; + renderUpdateUi(); + setUpdateStatus("Downloading update..."); + try { + await invoke("download_and_install_update"); + } catch (error) { + setUpdateStatus(String(error || "Update install failed."), "error"); + } finally { + updateState.downloading = false; + renderUpdateUi(); + } + } + + async function bindUpdaterEvents() { + if (updaterUnlisten || typeof listen !== "function") return; + updaterUnlisten = await listen("updater_status", (event) => { + const payload = (event && typeof event.payload === "object") ? event.payload : {}; + const phase = String(payload.phase || "").trim(); + if (payload.current_version) { + updateState.currentVersion = String(payload.current_version); + } + if (payload.version) { + updateState.latestVersion = String(payload.version); + } + if (phase === "checking") { + updateState.checking = true; + setUpdateStatus("Checking for updates..."); + } else if (phase === "available") { + updateState.available = true; + setUpdateStatus(`Update available: ${updateState.latestVersion}`, "success"); + } else if (phase === "no_update") { + updateState.available = false; + setUpdateStatus("You are up to date.", "success"); + } else if (phase === "downloading") { + updateState.downloading = true; + const downloaded = Number(payload.downloaded || 0); + const total = Number(payload.content_length || 0); + if (total > 0) { + const pct = Math.min(100, Math.round((downloaded / total) * 100)); + setUpdateStatus(`Downloading update... ${pct}%`); + } else { + setUpdateStatus("Downloading update..."); + } + } else if (phase === "downloaded") { + setUpdateStatus("Update downloaded. Installing..."); + } else if (phase === "installed") { + setUpdateStatus("Update installed. Restarting app...", "success"); + } else if (phase === "failed") { + updateState.checking = false; + updateState.downloading = false; + setUpdateStatus(formatUpdaterError(payload.message || "Update failed."), "error"); + } + if (phase === "available" || phase === "no_update" || phase === "installed") { + updateState.checking = false; + } + if (phase === "installed") { + updateState.downloading = false; + } + renderUpdateUi(); + }); + } function closeSettingsPanel() { if (!d.settingsPanel) return; @@ -349,6 +502,7 @@ export function createSettingsFeature({ } function bindUi() { + bindUpdaterEvents().catch(() => {}); if (d.settingsPanel) { d.settingsPanel.addEventListener("click", (event) => { if (event.target === d.settingsPanel) { @@ -432,7 +586,19 @@ export function createSettingsFeature({ persistAppSettings(); }); } + if (d.checkForUpdatesButton) { + d.checkForUpdatesButton.addEventListener("click", () => { + checkForUpdates(); + }); + } + if (d.installUpdateButton) { + d.installUpdateButton.addEventListener("click", () => { + installAvailableUpdate(); + }); + } + setUpdateStatus("No update check yet."); + renderUpdateUi(); renderAllSettingsSelectDropdowns(); } @@ -446,5 +612,7 @@ export function createSettingsFeature({ loadAppSettings, syncAppSettingsUI, persistAppSettings, + checkForUpdates, + installAvailableUpdate, }; } diff --git a/src/index.html b/src/index.html index d9e3a5f..a0b6535 100644 --- a/src/index.html +++ b/src/index.html @@ -246,10 +246,21 @@

Bindings

-
+
+
+
+
Maintenance
+
+
App Updates
+
Current-
+
Latest-
+
No update check yet.
+ + +
diff --git a/src/main.js b/src/main.js index 3aa50e8..c454646 100644 --- a/src/main.js +++ b/src/main.js @@ -369,6 +369,11 @@ const minimizeToTraySelect = document.getElementById("minimize-to-tray"); const exitToTraySelect = document.getElementById("exit-to-tray"); const openLogsFolderButton = document.getElementById("open-logs-folder"); const resetAppDataButton = document.getElementById("reset-app-data"); +const checkForUpdatesButton = document.getElementById("check-for-updates"); +const installUpdateButton = document.getElementById("install-update"); +const settingsUpdateStatus = document.getElementById("settings-update-status"); +const updateCurrentVersion = document.getElementById("update-current-version"); +const updateLatestVersion = document.getElementById("update-latest-version"); const osd = document.getElementById("volume-osd"); // OSD elements are now dynamic const alertOverlay = document.getElementById("alert-overlay"); @@ -842,6 +847,7 @@ let appStarted = false; // Feature modules settingsFeature = createSettingsFeature({ invoke, + listen, dom: { settingsButton, settingsPanel, @@ -854,6 +860,11 @@ settingsFeature = createSettingsFeature({ minimizeToTraySelect, exitToTraySelect, openLogsFolderButton, + checkForUpdatesButton, + installUpdateButton, + settingsUpdateStatus, + updateCurrentVersion, + updateLatestVersion, }, getOsdSettings: () => osdSettings, setOsdSettings: (next) => { osdSettings = next; }, @@ -1945,6 +1956,23 @@ async function init() { await hydrateClientPreferences(); mainScreen?.classList?.remove?.("hidden"); await startMainApp(); + settingsFeature?.checkForUpdates?.({ silent: true }).then((info) => { + if (!info || !info.available) return; + const latest = String(info.latestVersion || "").trim(); + const current = String(info.currentVersion || "").trim(); + if (!latest) return; + const key = `updaterPromptedVersion:${latest}`; + try { + if (localStorage.getItem(key) === "1") return; + localStorage.setItem(key, "1"); + } catch { + // ignore storage failures + } + showAlert( + "Update Available", + `MIDIMaster ${latest} is available (current: ${current || "unknown"}). Open Settings to install.`, + ); + }).catch(() => { }); } window.addEventListener("load", () => { diff --git a/src/styles/base.css b/src/styles/base.css index 28b1169..2a4dff9 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -512,6 +512,24 @@ body.dark-mode .settings-inline-status.success { color: #9ce7be; } +body.dark-mode .settings-update-meta { + color: #b9c8ea; +} + +body.dark-mode .settings-updates-section { + border-color: rgba(110, 146, 230, 0.45); + background: rgba(77, 110, 190, 0.15); +} + +body.dark-mode .settings-maintenance-section { + border-color: rgba(110, 146, 230, 0.45); + background: rgba(77, 110, 190, 0.15); +} + +body.dark-mode .settings-update-meta-label { + color: #b9c8ea; +} + /* Legacy support for old class name */ .settings-button { width: 36px; diff --git a/src/styles/bindings-and-panels.css b/src/styles/bindings-and-panels.css index 30c87f2..6a1d336 100644 --- a/src/styles/bindings-and-panels.css +++ b/src/styles/bindings-and-panels.css @@ -586,7 +586,15 @@ body.dark-mode .target-tag--count { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; - align-items: stretch; + align-items: start; +} + +.settings-bottom-grid { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 20px; + align-items: start; } .settings-column { @@ -594,7 +602,7 @@ body.dark-mode .target-tag--count { flex-direction: column; gap: 16px; align-items: flex-start; - height: 100%; + height: auto; } .settings-section { @@ -637,7 +645,18 @@ body.dark-mode .target-tag--count { .settings-reset-section { gap: 8px; - margin-top: auto; + margin-top: 0; +} + +.settings-maintenance-section { + padding: 10px 12px 12px; + border-radius: 10px; + border: 1px solid rgba(90, 112, 165, 0.35); + background: rgba(167, 188, 236, 0.08); +} + +.settings-updates-section { + margin-top: -52px !important; } .settings-inline-status { @@ -645,6 +664,40 @@ body.dark-mode .target-tag--count { line-height: 1.4; } +#settings-update-status { + min-height: 34px; + max-height: 34px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.settings-updates-section { + padding: 10px 12px 12px; + border-radius: 10px; + border: 1px solid rgba(90, 112, 165, 0.35); + background: rgba(167, 188, 236, 0.08); +} + +.settings-update-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; +} + +.settings-update-meta-label { + color: #55607a; + font-weight: 600; +} + +.settings-update-meta { + font-size: 12px; + color: #55607a; +} + .settings-inline-status.error { color: #a11f1f; } @@ -667,6 +720,14 @@ body.dark-mode .target-tag--count { .settings-grid { grid-template-columns: 1fr; } + + .settings-bottom-grid { + grid-template-columns: 1fr; + } + + .settings-updates-section { + margin-top: 0 !important; + } } .settings-section { @@ -715,8 +776,8 @@ body.dark-mode .target-tag--count { .osd-position-picker { position: relative; width: 100%; - max-width: 340px; - aspect-ratio: 3 / 2; + max-width: 330px; + aspect-ratio: 1.75 / 1; border-radius: 18px; border: 2px solid #2b2d42; background: #eef1fb; From 270e540a9af7b42f16d7ca16fb64e02270b35b77 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:41:31 -0400 Subject: [PATCH 15/25] chore(release): bump version to 1.6.0 --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 088d992..fd89cf5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.5.1" +version = "1.6.0" edition = "2021" default-run = "midimaster" license = "MIT" From 5bedf714da4ecffac8c6daab22208a46eece671e Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:26:13 -0400 Subject: [PATCH 16/25] feat(updater): refine startup update prompt and settings UX --- src-tauri/src/app_settings.rs | 2 + src-tauri/src/commands/settings.rs | 11 +- src-tauri/src/commands/updates.rs | 4 +- src-tauri/src/main.rs | 1 + src/app/alerts.js | 157 +++++++++++++++++++++++------ src/features/settings/settings.js | 65 ++++++++++-- src/index.html | 7 +- src/main.js | 66 ++++++++---- src/styles/base.css | 12 +++ src/styles/bindings-and-panels.css | 47 ++++++++- 10 files changed, 305 insertions(+), 67 deletions(-) diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index d10e099..6ad4d51 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -15,6 +15,7 @@ pub struct AppSettings { pub midi_input_device_name: Option, pub midi_output_device_name: Option, pub active_profile_name: Option, + pub auto_check_updates: bool, } impl Default for AppSettings { @@ -30,6 +31,7 @@ impl Default for AppSettings { midi_input_device_name: None, midi_output_device_name: None, active_profile_name: None, + auto_check_updates: true, } } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index cb9fb84..3daf3db 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -101,6 +101,11 @@ pub fn get_app_settings(state: State) -> Result { .map_err(|_| "Lock poisoned".to_string()) } +#[tauri::command] +pub fn get_app_version(app: AppHandle) -> String { + app.package_info().version.to_string() +} + #[tauri::command] pub fn update_app_settings( app: AppHandle, @@ -109,13 +114,14 @@ pub fn update_app_settings( start_in_tray: bool, minimize_to_tray: bool, exit_to_tray: bool, + auto_check_updates: bool, ) -> Result<(), String> { run_logger::info( "settings", "update_app_settings", &format!( - "start_with_windows={} start_in_tray={} minimize_to_tray={} exit_to_tray={}", - start_with_windows, start_in_tray, minimize_to_tray, exit_to_tray + "start_with_windows={} start_in_tray={} minimize_to_tray={} exit_to_tray={} auto_check_updates={}", + start_with_windows, start_in_tray, minimize_to_tray, exit_to_tray, auto_check_updates ), ); let mut settings = state @@ -126,6 +132,7 @@ pub fn update_app_settings( settings.start_in_tray = start_in_tray; settings.minimize_to_tray = minimize_to_tray; settings.exit_to_tray = exit_to_tray; + settings.auto_check_updates = auto_check_updates; let updated = settings.clone(); drop(settings); diff --git a/src-tauri/src/commands/updates.rs b/src-tauri/src/commands/updates.rs index e102105..9102fa9 100644 --- a/src-tauri/src/commands/updates.rs +++ b/src-tauri/src/commands/updates.rs @@ -71,7 +71,7 @@ pub async fn check_for_updates(app: AppHandle) -> Result { phase: "no_update".to_string(), message: Some("No update available.".to_string()), current_version: Some(current_version.clone()), - version: None, + version: Some(current_version.clone()), downloaded: None, content_length: None, }, @@ -79,7 +79,7 @@ pub async fn check_for_updates(app: AppHandle) -> Result { return Ok(UpdateInfo { available: false, current_version, - version: None, + version: Some(app.package_info().version.to_string()), body: None, date: None, }); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c9ecceb..73a4b3d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1999,6 +1999,7 @@ fn main() { get_osd_settings, update_osd_settings, get_app_settings, + get_app_version, update_app_settings, set_theme_preference, set_midi_device_preferences, diff --git a/src/app/alerts.js b/src/app/alerts.js index e3cd322..4dbf925 100644 --- a/src/app/alerts.js +++ b/src/app/alerts.js @@ -3,28 +3,64 @@ export function createAlertsController({ alertTitle, alertMessage, alertClose, + alertSecondary, alertCancel, alertOk, }) { - let pendingConfirmResolve = null; + let pendingChoiceResolve = null; + let pendingMode = "alert"; - function resolveConfirm(value) { - if (!pendingConfirmResolve) return; - const resolve = pendingConfirmResolve; - pendingConfirmResolve = null; - resolve(Boolean(value)); + function resolveChoice(value) { + if (!pendingChoiceResolve) return; + const resolve = pendingChoiceResolve; + pendingChoiceResolve = null; + pendingMode = "alert"; + resolve(value); } - function setActionsMode({ - confirm = false, - confirmLabel = "OK", - cancelLabel = "Cancel", - } = {}) { + function setButtonConfig(button, config = null) { + if (!button) return; + if (!config) { + button.classList.add("hidden"); + button.classList.remove("primary-button", "secondary-button", "secondary"); + delete button.dataset.choiceId; + return; + } + button.textContent = config.label || ""; + button.dataset.choiceId = config.id || ""; + button.classList.remove("hidden"); + const isPrimary = config.variant === "primary"; + button.classList.toggle("secondary", !isPrimary); + button.classList.toggle("secondary-button", !isPrimary); + button.classList.toggle("primary-button", isPrimary); + } + + function setActionsMode(mode = "alert", config = {}) { + pendingMode = mode; if (!alertOk) return; - alertOk.textContent = confirmLabel; - if (alertCancel) { - alertCancel.textContent = cancelLabel; - alertCancel.classList.toggle("hidden", !confirm); + if (mode === "alert") { + setButtonConfig(alertSecondary, null); + setButtonConfig(alertCancel, null); + setButtonConfig(alertOk, { label: "OK", variant: "primary" }); + return; + } + if (mode === "confirm") { + setButtonConfig(alertSecondary, null); + setButtonConfig(alertCancel, { label: config.cancelLabel || "Cancel", variant: "secondary" }); + setButtonConfig(alertOk, { label: config.confirmLabel || "Confirm", variant: "primary" }); + return; + } + if (mode === "choice") { + const options = Array.isArray(config.options) ? config.options : []; + if (options.length === 2) { + setButtonConfig(alertSecondary, options[0] || null); + setButtonConfig(alertCancel, null); + setButtonConfig(alertOk, options[1] || null); + return; + } + setButtonConfig(alertSecondary, options[0] || null); + setButtonConfig(alertCancel, options[1] || null); + setButtonConfig(alertOk, options[2] || null); } } @@ -32,11 +68,11 @@ export function createAlertsController({ if (!alertOverlay || !alertMessage) { return; } - resolveConfirm(false); + resolveChoice("close"); if (alertTitle) { alertTitle.textContent = title; } - setActionsMode({ confirm: false, confirmLabel: "OK" }); + setActionsMode("alert"); alertMessage.textContent = message; alertOverlay.classList.remove("hidden"); } @@ -50,24 +86,50 @@ export function createAlertsController({ if (!alertOverlay || !alertMessage) { return Promise.resolve(false); } - resolveConfirm(false); + resolveChoice("cancel"); + if (alertTitle) { + alertTitle.textContent = title; + } + setActionsMode("confirm", { confirmLabel, cancelLabel }); + alertMessage.textContent = message; + alertOverlay.classList.remove("hidden"); + return new Promise((resolve) => { + pendingChoiceResolve = (value) => resolve(value === "confirm"); + }); + } + + function showChoices({ + title = "Choose", + message = "", + options = [], + } = {}) { + if (!alertOverlay || !alertMessage) { + return Promise.resolve("close"); + } + const safeOptions = Array.isArray(options) + ? options.filter((option) => option && typeof option.id === "string") + : []; + if (safeOptions.length < 2 || safeOptions.length > 3) { + return Promise.resolve("close"); + } + resolveChoice("close"); if (alertTitle) { alertTitle.textContent = title; } - setActionsMode({ confirm: true, confirmLabel, cancelLabel }); + setActionsMode("choice", { options: safeOptions }); alertMessage.textContent = message; alertOverlay.classList.remove("hidden"); return new Promise((resolve) => { - pendingConfirmResolve = resolve; + pendingChoiceResolve = resolve; }); } function closeAlert() { - resolveConfirm(false); + resolveChoice("close"); if (alertOverlay) { alertOverlay.classList.add("hidden"); } - setActionsMode({ confirm: false, confirmLabel: "OK" }); + setActionsMode("alert"); } function bindUi() { @@ -75,18 +137,54 @@ export function createAlertsController({ alertClose.addEventListener("click", closeAlert); } - if (alertCancel) { - alertCancel.addEventListener("click", closeAlert); - } - if (alertOk) { alertOk.addEventListener("click", () => { - if (pendingConfirmResolve) { - resolveConfirm(true); + if (pendingChoiceResolve) { + if (pendingMode === "confirm") { + resolveChoice("confirm"); + } else if (pendingMode === "choice") { + resolveChoice(alertOk.dataset.choiceId || "close"); + } else { + resolveChoice("close"); + } + if (alertOverlay) { + alertOverlay.classList.add("hidden"); + } + setActionsMode("alert"); + return; + } + closeAlert(); + }); + } + + if (alertSecondary) { + alertSecondary.addEventListener("click", () => { + if (pendingChoiceResolve && pendingMode === "choice") { + resolveChoice(alertSecondary.dataset.choiceId || "close"); + if (alertOverlay) { + alertOverlay.classList.add("hidden"); + } + setActionsMode("alert"); + return; + } + closeAlert(); + }); + } + + if (alertCancel) { + alertCancel.addEventListener("click", () => { + if (pendingChoiceResolve) { + if (pendingMode === "confirm") { + resolveChoice("cancel"); + } else if (pendingMode === "choice") { + resolveChoice(alertCancel.dataset.choiceId || "close"); + } else { + resolveChoice("close"); + } if (alertOverlay) { alertOverlay.classList.add("hidden"); } - setActionsMode({ confirm: false, confirmLabel: "OK" }); + setActionsMode("alert"); return; } closeAlert(); @@ -105,6 +203,7 @@ export function createAlertsController({ return { showAlert, showConfirm, + showChoices, closeAlert, bindUi, }; diff --git a/src/features/settings/settings.js b/src/features/settings/settings.js index 4160f97..7f421a4 100644 --- a/src/features/settings/settings.js +++ b/src/features/settings/settings.js @@ -37,6 +37,15 @@ export function createSettingsFeature({ downloading: false, }; + function renderAutoCheckButton() { + if (!d.autoCheckUpdatesButton) return; + const enabled = (typeof getAppSettings === "function") + ? ((getAppSettings() || {}).autoCheckUpdates !== false) + : true; + d.autoCheckUpdatesButton.dataset.enabled = enabled ? "true" : "false"; + d.autoCheckUpdatesButton.textContent = enabled ? "Auto-check: On" : "Auto-check: Off"; + } + function formatUpdaterError(error) { const message = String(error || "Update check failed."); const normalized = message.toLowerCase(); @@ -70,12 +79,18 @@ export function createSettingsFeature({ d.updateLatestVersion.textContent = updateState.latestVersion || "-"; } if (d.checkForUpdatesButton) { + if (updateState.downloading) { + d.checkForUpdatesButton.textContent = "Downloading..."; + } else if (updateState.checking) { + d.checkForUpdatesButton.textContent = "Checking..."; + } else if (updateState.available) { + d.checkForUpdatesButton.textContent = "Download and install"; + } else { + d.checkForUpdatesButton.textContent = "Check for updates"; + } d.checkForUpdatesButton.disabled = updateState.checking || updateState.downloading; } - if (d.installUpdateButton) { - d.installUpdateButton.classList.toggle("hidden", !updateState.available); - d.installUpdateButton.disabled = !updateState.available || updateState.checking || updateState.downloading; - } + renderAutoCheckButton(); } function normalizeUpdateInfo(updateInfo) { @@ -83,7 +98,7 @@ export function createSettingsFeature({ const available = Boolean(info.available); const currentVersion = String(info.current_version ?? info.currentVersion ?? updateState.currentVersion ?? "-"); const latestVersionRaw = info.version ?? null; - const latestVersion = latestVersionRaw ? String(latestVersionRaw) : "-"; + const latestVersion = latestVersionRaw ? String(latestVersionRaw) : currentVersion; const body = info.body ? String(info.body) : ""; return { available, currentVersion, latestVersion, body }; } @@ -468,6 +483,7 @@ export function createSettingsFeature({ d.exitToTraySelect.value = merged.exitToTray ? "enabled" : "disabled"; renderSettingsSelectDropdown(d.exitToTraySelect); } + renderAutoCheckButton(); } function persistAppSettings() { @@ -477,6 +493,7 @@ export function createSettingsFeature({ startInTray: Boolean(s.startInTray), minimizeToTray: Boolean(s.minimizeToTray), exitToTray: Boolean(s.exitToTray), + autoCheckUpdates: s.autoCheckUpdates !== false, }).catch((error) => { console.error("Failed to update app settings", error); }); @@ -491,6 +508,7 @@ export function createSettingsFeature({ startInTray: Boolean(settings.start_in_tray ?? settings.startInTray), minimizeToTray: Boolean(settings.minimize_to_tray ?? settings.minimizeToTray), exitToTray: Boolean(settings.exit_to_tray ?? settings.exitToTray), + autoCheckUpdates: Boolean(settings.auto_check_updates ?? settings.autoCheckUpdates ?? true), }; if (typeof setAppSettings === "function") { setAppSettings(next); @@ -519,8 +537,12 @@ export function createSettingsFeature({ await loadOsdSettings(); await loadMonitorOptions(); await loadAppSettings(); + await loadCurrentAppVersion(); syncAppSettingsUI((typeof getAppSettings === "function") ? (getAppSettings() || {}) : {}); renderAllSettingsSelectDropdowns(); + if ((getAppSettings?.() || {}).autoCheckUpdates !== false) { + await checkForUpdates({ silent: true }); + } openSettingsPanel(); }); } @@ -586,22 +608,44 @@ export function createSettingsFeature({ persistAppSettings(); }); } + if (d.autoCheckUpdatesButton) { + d.autoCheckUpdatesButton.addEventListener("click", () => { + const enabled = (getAppSettings?.() || {}).autoCheckUpdates !== false; + syncAppSettingsUI({ autoCheckUpdates: !enabled }); + persistAppSettings(); + renderUpdateUi(); + }); + } if (d.checkForUpdatesButton) { d.checkForUpdatesButton.addEventListener("click", () => { + if (updateState.available) { + installAvailableUpdate(); + return; + } checkForUpdates(); }); } - if (d.installUpdateButton) { - d.installUpdateButton.addEventListener("click", () => { - installAvailableUpdate(); - }); - } setUpdateStatus("No update check yet."); renderUpdateUi(); renderAllSettingsSelectDropdowns(); } + async function loadCurrentAppVersion() { + try { + const version = await invoke("get_app_version"); + if (version) { + updateState.currentVersion = String(version); + if (!updateState.latestVersion || updateState.latestVersion === "-") { + updateState.latestVersion = updateState.currentVersion; + } + renderUpdateUi(); + } + } catch { + // ignore version fetch failures + } + } + return { bindUi, openSettingsPanel, @@ -610,6 +654,7 @@ export function createSettingsFeature({ loadOsdSettings, applyOsdSettings, loadAppSettings, + loadCurrentAppVersion, syncAppSettingsUI, persistAppSettings, checkForUpdates, diff --git a/src/index.html b/src/index.html index a0b6535..292eff9 100644 --- a/src/index.html +++ b/src/index.html @@ -40,6 +40,7 @@

+
@@ -254,12 +255,14 @@

Bindings

-
App Updates
+
+
App Updates
+ +
Current-
Latest-
No update check yet.
-
diff --git a/src/main.js b/src/main.js index c454646..7d78a98 100644 --- a/src/main.js +++ b/src/main.js @@ -367,10 +367,10 @@ const startWithWindowsSelect = document.getElementById("start-with-windows"); const startInTraySelect = document.getElementById("start-in-tray"); const minimizeToTraySelect = document.getElementById("minimize-to-tray"); const exitToTraySelect = document.getElementById("exit-to-tray"); +const autoCheckUpdatesButton = document.getElementById("auto-check-updates-button"); const openLogsFolderButton = document.getElementById("open-logs-folder"); const resetAppDataButton = document.getElementById("reset-app-data"); const checkForUpdatesButton = document.getElementById("check-for-updates"); -const installUpdateButton = document.getElementById("install-update"); const settingsUpdateStatus = document.getElementById("settings-update-status"); const updateCurrentVersion = document.getElementById("update-current-version"); const updateLatestVersion = document.getElementById("update-latest-version"); @@ -380,6 +380,7 @@ const alertOverlay = document.getElementById("alert-overlay"); const alertTitle = document.getElementById("alert-title"); const alertMessage = document.getElementById("alert-message"); const alertClose = document.getElementById("alert-close"); +const alertSecondary = document.getElementById("alert-secondary"); const alertCancel = document.getElementById("alert-cancel"); const alertOk = document.getElementById("alert-ok"); @@ -841,6 +842,7 @@ let appSettings = { startInTray: false, minimizeToTray: false, exitToTray: false, + autoCheckUpdates: true, }; let appStarted = false; @@ -859,9 +861,9 @@ settingsFeature = createSettingsFeature({ startInTraySelect, minimizeToTraySelect, exitToTraySelect, + autoCheckUpdatesButton, openLogsFolderButton, checkForUpdatesButton, - installUpdateButton, settingsUpdateStatus, updateCurrentVersion, updateLatestVersion, @@ -1352,10 +1354,12 @@ const alertsController = createAlertsController({ alertTitle, alertMessage, alertClose, + alertSecondary, alertCancel, alertOk, }); const showAlert = (title, message = "") => alertsController.showAlert(message, title); +const showChoices = (options = {}) => alertsController.showChoices(options); const closeAlert = (...args) => alertsController.closeAlert(...args); connectionsController?.bindUi?.(); @@ -1956,23 +1960,49 @@ async function init() { await hydrateClientPreferences(); mainScreen?.classList?.remove?.("hidden"); await startMainApp(); - settingsFeature?.checkForUpdates?.({ silent: true }).then((info) => { - if (!info || !info.available) return; - const latest = String(info.latestVersion || "").trim(); - const current = String(info.currentVersion || "").trim(); - if (!latest) return; - const key = `updaterPromptedVersion:${latest}`; - try { - if (localStorage.getItem(key) === "1") return; - localStorage.setItem(key, "1"); - } catch { - // ignore storage failures + try { + const resetSkipOnceKey = "updaterResetSkipOnce"; + if (localStorage.getItem(resetSkipOnceKey) !== "1") { + localStorage.removeItem("updaterSkippedVersion"); + localStorage.setItem(resetSkipOnceKey, "1"); } - showAlert( - "Update Available", - `MIDIMaster ${latest} is available (current: ${current || "unknown"}). Open Settings to install.`, - ); - }).catch(() => { }); + } catch { + // ignore storage failures + } + if (appSettings.autoCheckUpdates !== false) { + settingsFeature?.checkForUpdates?.({ silent: true }).then((info) => { + if (!info || !info.available) return; + const latest = String(info.latestVersion || "").trim(); + const current = String(info.currentVersion || "").trim(); + if (!latest) return; + const skippedVersionKey = "updaterSkippedVersion"; + try { + if (localStorage.getItem(skippedVersionKey) === latest) return; + } catch { + // ignore storage failures + } + showChoices({ + title: "Update Available", + message: `MIDIMaster ${latest} is available (current: ${current || "unknown"})`, + options: [ + { id: "skip", label: "Skip Update", variant: "secondary" }, + { id: "install", label: "Download and Install", variant: "primary" }, + ], + }).then((choice) => { + if (choice === "skip") { + try { + localStorage.setItem(skippedVersionKey, latest); + } catch { + // ignore storage failures + } + return; + } + if (choice === "install") { + settingsFeature?.installAvailableUpdate?.(); + } + }); + }).catch(() => { }); + } } window.addEventListener("load", () => { diff --git a/src/styles/base.css b/src/styles/base.css index 2a4dff9..dc4530e 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -530,6 +530,18 @@ body.dark-mode .settings-update-meta-label { color: #b9c8ea; } +body.dark-mode .settings-mini-toggle { + border-color: #5570a7; + background: #25365a; + color: #e6eeff; +} + +body.dark-mode .settings-mini-toggle[data-enabled="false"] { + border-color: #4c5f89; + background: #1f2c47; + color: #b2c2e3; +} + /* Legacy support for old class name */ .settings-button { width: 36px; diff --git a/src/styles/bindings-and-panels.css b/src/styles/bindings-and-panels.css index 6a1d336..4a70c15 100644 --- a/src/styles/bindings-and-panels.css +++ b/src/styles/bindings-and-panels.css @@ -693,6 +693,31 @@ body.dark-mode .target-tag--count { font-weight: 600; } +.settings-update-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.settings-mini-toggle { + border-radius: 999px; + border: 1px solid #4a5f8a; + background: #22304a; + color: #e8eefc; + font-size: 11px; + font-weight: 700; + padding: 4px 10px; + line-height: 1.2; + white-space: nowrap; +} + +.settings-mini-toggle[data-enabled="false"] { + border-color: #627093; + background: #1f2840; + color: #bfc9de; +} + .settings-update-meta { font-size: 12px; color: #55607a; @@ -1301,17 +1326,19 @@ body.dragging-binding { } .alert-panel-content { - width: min(460px, 92vw); + width: fit-content; + max-width: 92vw; border-radius: 12px; } .alert-panel-body { padding: 20px 22px 24px; + max-width: min(520px, calc(92vw - 2px)); } .alert-panel-body p { margin: 0; - text-align: left; + text-align: center; font-size: 15px; line-height: 1.5; color: #2b2d42; @@ -1319,13 +1346,25 @@ body.dragging-binding { .alert-panel-actions { display: flex; - justify-content: center; + justify-content: flex-start; + width: 100%; + align-items: center; gap: 10px; margin-top: 18px; } .alert-panel-actions button { - min-width: 100px; + flex: 0 0 auto; + min-width: 0; + white-space: nowrap; +} + +#alert-secondary { + min-width: 128px; +} + +#alert-ok { + min-width: 170px; } .alert-panel-actions .secondary { From aa2167ca9b41116bb331d45623a09143384a9819 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:26:44 -0400 Subject: [PATCH 17/25] chore(release): bump version to 1.7.0 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cf7e6d1..1952530 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2191,7 +2191,7 @@ dependencies = [ [[package]] name = "midimaster" -version = "1.5.1" +version = "1.7.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fd89cf5..6523afc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.6.0" +version = "1.7.0" edition = "2021" default-run = "midimaster" license = "MIT" From b5f010627489f39a6f2808c2d32a154adcce47ea Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:10:18 -0400 Subject: [PATCH 18/25] fix(audio/windows): prevent master 0% writes from leaving endpoint muted --- src-tauri/src/audio/windows.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src-tauri/src/audio/windows.rs b/src-tauri/src/audio/windows.rs index db44aec..d698ebb 100644 --- a/src-tauri/src/audio/windows.rs +++ b/src-tauri/src/audio/windows.rs @@ -116,7 +116,17 @@ impl AudioBackend for WindowsAudioBackend { let device = get_default_device()?; let endpoint = get_endpoint_volume(&device)?; let clamped = volume.clamp(0.0, 1.0); + let previous_volume = unsafe { endpoint.GetMasterVolumeLevelScalar() }?; + let previous_muted = unsafe { endpoint.GetMute() }?.as_bool(); unsafe { endpoint.SetMasterVolumeLevelScalar(clamped, std::ptr::null()) }?; + // Keep mute and volume independent for master endpoint writes. + // Some Windows systems auto-mute at 0 volume; this ensures that: + // - writing 0% does not force a muted state + // - raising from a 0%+muted state restores audible output + let was_effectively_zero = previous_volume <= f32::EPSILON; + if clamped <= f32::EPSILON || (previous_muted && was_effectively_zero && clamped > 0.0) { + unsafe { endpoint.SetMute(false, std::ptr::null()) }?; + } Ok(()) } From b90d51b3f558c4042d34d4e39757a23e5c24e6ba Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:16:34 -0400 Subject: [PATCH 19/25] fix(ui): prevent settings overflow at small window heights --- src-tauri/tauri.conf.json | 2 +- src/styles/bindings-and-panels.css | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1a8f922..71304d7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "width": 1040, "height": 740, "minWidth": 1040, - "minHeight": 600, + "minHeight": 740, "visible": false } ], diff --git a/src/styles/bindings-and-panels.css b/src/styles/bindings-and-panels.css index 4a70c15..f5a6b49 100644 --- a/src/styles/bindings-and-panels.css +++ b/src/styles/bindings-and-panels.css @@ -580,6 +580,8 @@ body.dark-mode .target-tag--count { .settings-panel-body { padding: 18px 16px 24px; + min-height: 0; + overflow-y: auto; } .settings-grid { From 0a897840a2865eb2f10f6ebfd6f63136e3cfa1e6 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:16:58 -0400 Subject: [PATCH 20/25] chore(release): bump version to 1.8.0 --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6523afc..e1c085e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.7.0" +version = "1.8.0" edition = "2021" default-run = "midimaster" license = "MIT" From 5fd6e19064295f154b48fccf7f39bcf2b1f5e8bb Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:06:55 -0400 Subject: [PATCH 21/25] chore(store): trust rotated key and ignore local store tooling --- .gitignore | 13 +++++++++++++ src-tauri/src/store_api.rs | 3 +++ 2 files changed, 16 insertions(+) diff --git a/.gitignore b/.gitignore index 83036f6..7215a22 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,16 @@ target/ # Tauri src-tauri/gen/ + +# Netlify local metadata +.netlify/ +netlify.toml + +# Local store/operator workflow (keep private, local-only) +plugin-staging/ +scripts/store/ +scripts/local-store/ +docs/STORE_OPERATOR_GUIDE.md +src-tauri/src/bin/store_tool.rs +store-private-key*.txt +*.midimaster.key diff --git a/src-tauri/src/store_api.rs b/src-tauri/src/store_api.rs index ed31eb6..55fb2e5 100644 --- a/src-tauri/src/store_api.rs +++ b/src-tauri/src/store_api.rs @@ -21,6 +21,9 @@ fn official_store_url() -> String { pub const TRUSTED_KEYS: &[(&str, &str)] = &[( "official-2026-01", "/a99SbJ8PwG4zpPXkpCAAndQ7hZWmb2eSYIFE3lCLts=", +), ( + "official-2026-02", + "ugJkWqxrzUfjgFyzZWnQCbMhBSOSWJ+WwPF0MBgfh6U=", )]; #[derive(Debug, Clone, Serialize, Deserialize)] From fbee0533244268bb166d1085b9adcfadef59c4fe Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:11:23 -0400 Subject: [PATCH 22/25] feat(tray): open app on left-click and show menu on right-click --- src-tauri/src/main.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 73a4b3d..111c2d7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -38,7 +38,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::menu::{Menu, MenuEvent, MenuItem}; -use tauri::tray::TrayIconBuilder; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{ AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl, WebviewWindowBuilder, }; @@ -1802,11 +1802,28 @@ fn main() { let show_item = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?; - let mut tray_builder = TrayIconBuilder::new().menu(&tray_menu); + let mut tray_builder = TrayIconBuilder::new() + .menu(&tray_menu) + .show_menu_on_left_click(false); if let Some(icon) = app.default_window_icon().cloned() { tray_builder = tray_builder.icon(icon); } tray_builder + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + }) .on_menu_event( |app: &AppHandle, event: MenuEvent| match event.id().as_ref() { "show" => { From c2d682ed630ba1e7072720d42c47245ea2b09910 Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:12:22 -0400 Subject: [PATCH 23/25] chore(release): bump version to 1.9.0 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1952530..71abc54 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2191,7 +2191,7 @@ dependencies = [ [[package]] name = "midimaster" -version = "1.7.0" +version = "1.9.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1c085e..6ed9769 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "midimaster" -version = "1.8.0" +version = "1.9.0" edition = "2021" default-run = "midimaster" license = "MIT" From 4d893ad4291b79fe6e2a90fac021d43d0be92c3c Mon Sep 17 00:00:00 2001 From: prgmitchell <86465454+prgmitchell@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:16:48 -0400 Subject: [PATCH 24/25] fix(ci): format store_api and enforce pre-push rustfmt check --- .githooks/pre-push | 8 ++++++++ src-tauri/src/store_api.rs | 17 ++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) create mode 100755 .githooks/pre-push diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..cab3d3d --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +echo "[pre-push] Running Rust format check..." +cd src-tauri +cargo fmt --all -- --check + +echo "[pre-push] Format check passed." diff --git a/src-tauri/src/store_api.rs b/src-tauri/src/store_api.rs index 55fb2e5..7934aff 100644 --- a/src-tauri/src/store_api.rs +++ b/src-tauri/src/store_api.rs @@ -18,13 +18,16 @@ fn official_store_url() -> String { // Trusted public keys (hardcoded). // key_id -> base64(ed25519 public key bytes) -pub const TRUSTED_KEYS: &[(&str, &str)] = &[( - "official-2026-01", - "/a99SbJ8PwG4zpPXkpCAAndQ7hZWmb2eSYIFE3lCLts=", -), ( - "official-2026-02", - "ugJkWqxrzUfjgFyzZWnQCbMhBSOSWJ+WwPF0MBgfh6U=", -)]; +pub const TRUSTED_KEYS: &[(&str, &str)] = &[ + ( + "official-2026-01", + "/a99SbJ8PwG4zpPXkpCAAndQ7hZWmb2eSYIFE3lCLts=", + ), + ( + "official-2026-02", + "ugJkWqxrzUfjgFyzZWnQCbMhBSOSWJ+WwPF0MBgfh6U=", + ), +]; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoreCatalog { From fa928dcce3c2f3d717afd64528a912ea8eaf0a0b Mon Sep 17 00:00:00 2001 From: Alex Hansen Date: Fri, 17 Apr 2026 16:37:15 -0400 Subject: [PATCH 25/25] feat(plugins): add ctx.osd namespace for plugin-driven OSD control - New plugin_show_osd / plugin_hide_osd Tauri commands with show_bar / show_value flags so plugins can render text-only OSD cards. - osd_settings_changed event emitted from apply_osd_settings so plugins can react to OSD enable/anchor/monitor changes. - Skip default volume_update / mute_update OSD emits for Integration targets so a plugin fully owns the OSD for its own targets (no duplicate cards). Slider sync is preserved via set_binding_feedback. - plugin_host: ctx.osd.{showVolume, showMute, hide, getSettings, onSettingsChanged}. - osd feature: showVolumeOsd / showMuteOsd honor opts.showBar and opts.showValue for text-only rendering. - Docs updated (section 6.6, existing sections renumbered). --- docs/PLUGIN_DEVELOPER_GUIDE.md | 73 ++++++++++++++++++++-- src-tauri/src/commands/settings.rs | 99 +++++++++++++++++++++++++++++- src-tauri/src/main.rs | 17 +++++ src/features/osd/osd.js | 26 ++++++-- src/main.js | 21 +++++++ src/plugin_host.js | 60 ++++++++++++++++++ 6 files changed, 286 insertions(+), 10 deletions(-) diff --git a/docs/PLUGIN_DEVELOPER_GUIDE.md b/docs/PLUGIN_DEVELOPER_GUIDE.md index b74d69a..d58a32b 100644 --- a/docs/PLUGIN_DEVELOPER_GUIDE.md +++ b/docs/PLUGIN_DEVELOPER_GUIDE.md @@ -384,7 +384,72 @@ Use `silent: true` for: - Reconnect sync - Motor fader alignment -### 6.6 `ctx.ws` (WebSocket bridge) +### 6.6 `ctx.osd` (On-screen display) + +Direct control of the OSD overlay for plugin-driven feedback that is not +tied to a specific binding (e.g. a status update, an external event echo, +a notification triggered from a connection tab). + +If the feedback *is* tied to a binding, prefer `ctx.feedback.set(...)` — +it updates the UI, OSD, and motor faders in one call. Use `ctx.osd.*` +only when you need to show an OSD card without touching binding state. + +API: + +- `await ctx.osd.showVolume(target, volume, opts?)` +- `await ctx.osd.showMute(target, muted, opts?)` +- `await ctx.osd.hide()` +- `await ctx.osd.getSettings()` -> `{ enabled, monitor_index, monitor_name, monitor_id, anchor }` +- `ctx.osd.onSettingsChanged(handler)` -> unsubscribe fn + +`target` is the same shape used in bindings — e.g. `"Master"`, +`{ Session: { name: "Discord" } }`, or an integration target like +`{ Integration: { integration_id: "my-plugin", target_id: "room/living" } }`. + +`opts`: + +- `focusSession` — override the label/icon used for `Focus` targets. +- `showBar` (default `true`) — set `false` to hide the volume bar, e.g. for + text-only status messages where the bar is meaningless. +- `showValue` (default `true`) — set `false` to hide the percent/icon on the + right side of the card. + +Integration targets bound to bindings skip the default OSD card, so a plugin +owns the OSD for its own targets. Pair `ctx.osd.showVolume(...)` with +`ctx.feedback.set(binding_id, value, action, { silent: true })` to update +the controller/UI without emitting a duplicate OSD card. + +To keep repeated calls reusing the same card (rather than stacking a new +card per message), put dynamic text in `data.label`. The OSD key-builder +strips `label` when computing the target key, so calls with the same +`integration_id` + `kind` + remaining `data` collapse onto one card. + +```js +await ctx.osd.showVolume({ Integration: { integration_id: "my-plugin", target_id: "zone/a" } }, 0.65); +await ctx.osd.showMute("Master", true); +await ctx.osd.hide(); + +// Text-only banner (no bar, no value) reusing a single card. +const textTarget = (msg) => ({ + Integration: { integration_id: "my-plugin", kind: "status", data: { label: msg } }, +}); +await ctx.osd.showVolume(textTarget("Connected"), 0, { showBar: false, showValue: false }); + +const settings = await ctx.osd.getSettings(); +if (!settings.enabled) { + // OSD window is disabled — user-facing cues should use another channel +} + +const off = ctx.osd.onSettingsChanged((next) => { + console.log("OSD anchor is now", next.anchor); +}); +// off(); when the plugin unloads +``` + +OSD visibility still respects the user's global OSD toggle. Calls from a +plugin while the OSD is disabled are silently dropped. + +### 6.7 `ctx.ws` (WebSocket bridge) Use this when you need custom headers or consistent backend-managed sockets. @@ -406,7 +471,7 @@ Message handler receives: - `{ id, type: "text", data: string }` - `{ id, type: "binary", data: base64String }` -### 6.7 `ctx.assets` (Read plugin assets) +### 6.8 `ctx.assets` (Read plugin assets) - `await ctx.assets.readBase64(relPath)` -> base64 string - `await ctx.assets.readDataUrl(relPath, mime)` -> `data:;base64,...` @@ -417,7 +482,7 @@ Example: const icon = await ctx.assets.readDataUrl("icon.svg", "image/svg+xml"); ``` -### 6.8 `ctx.tauri` (Low-level) +### 6.9 `ctx.tauri` (Low-level) Advanced escape hatch: @@ -426,7 +491,7 @@ Advanced escape hatch: Prefer stable APIs (`ws`, `feedback`, etc.) when possible. -### 6.9 `ctx.app.invalidateBindingsUI()` +### 6.10 `ctx.app.invalidateBindingsUI()` If your plugin's connection/availability state changes, call: diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 3daf3db..2fb4c39 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -3,8 +3,9 @@ use crate::{ model::OsdSettings, run_logger, AppState, }; use serde::Serialize; +use serde_json::Value; use std::process::Command; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, Manager, State}; #[derive(Clone, Serialize)] pub struct MonitorInfo { @@ -92,6 +93,102 @@ pub fn update_osd_settings( Ok(()) } +fn is_mute_action(action: &str) -> bool { + matches!( + action.to_ascii_lowercase().as_str(), + "togglemute" | "toggle_mute" | "mute" | "unmute" + ) +} + +#[tauri::command] +pub fn plugin_show_osd( + app: AppHandle, + state: State, + target: Value, + action: String, + volume: Option, + muted: Option, + focus_session: Option, + show_bar: Option, + show_value: Option, +) -> Result<(), String> { + let mut payload = serde_json::json!({ "target": target }); + if is_mute_action(&action) { + payload["action"] = Value::from("toggle_mute"); + payload["muted"] = Value::from(muted.unwrap_or(false)); + } else { + let clamped = volume.unwrap_or(0.0).clamp(0.0, 1.0); + payload["volume"] = Value::from(clamped); + } + if let Some(session) = focus_session { + payload["focus_session"] = session; + } + if let Some(flag) = show_bar { + payload["show_bar"] = Value::from(flag); + } + if let Some(flag) = show_value { + payload["show_value"] = Value::from(flag); + } + + let settings_enabled = state + .osd_settings + .lock() + .map(|settings| settings.enabled) + .unwrap_or(true); + + let _ = app.emit("plugin_osd", payload.clone()); + + if settings_enabled { + if let Some(osd_window) = app.get_webview_window("osd") { + let _ = osd_window.show(); + let _ = osd_window.set_always_on_top(true); + #[cfg(target_os = "windows")] + if let Ok(hwnd) = osd_window.hwnd() { + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, + }; + unsafe { + let _ = SetWindowPos( + HWND(hwnd.0 as _), + Some(HWND_TOPMOST), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE, + ); + } + } + let _ = osd_window.emit("plugin_osd", payload.clone()); + if let Ok(payload_json) = serde_json::to_string(&payload) { + let script = format!( + "window.__OSD_UPDATE__ && window.__OSD_UPDATE__({});", + payload_json + ); + let _ = osd_window.eval(&script); + } + } + } + + run_logger::debug( + "plugins", + "plugin_show_osd", + &format!("action={} payload={}", action, payload), + ); + Ok(()) +} + +#[tauri::command] +pub fn plugin_hide_osd(app: AppHandle) -> Result<(), String> { + let _ = app.emit("plugin_osd_hide", ()); + if let Some(osd_window) = app.get_webview_window("osd") { + let _ = osd_window.emit("plugin_osd_hide", ()); + let _ = osd_window.eval("window.__OSD_HIDE__ && window.__OSD_HIDE__();"); + } + Ok(()) +} + #[tauri::command] pub fn get_app_settings(state: State) -> Result { state diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 111c2d7..8168b9a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -145,6 +145,8 @@ impl AppState { } fn apply_osd_settings(app: &AppHandle, settings: &OsdSettings) { + let _ = app.emit("osd_settings_changed", settings.clone()); + let Some(osd_window) = app.get_webview_window("osd") else { return; }; @@ -648,6 +650,10 @@ impl AppState { .unwrap_or(true); for target in &targets { + // Integration targets delegate OSD entirely to the plugin. + if matches!(target, model::BindingTarget::Integration { .. }) { + continue; + } let focus_session = if matches!(target, model::BindingTarget::Focus) { self.audio.focused_session().ok().flatten() } else { @@ -1118,6 +1124,10 @@ impl AppState { .unwrap_or(true); for target in &targets { + // Integration targets delegate OSD entirely to the plugin via ctx.osd. + if matches!(target, model::BindingTarget::Integration { .. }) { + continue; + } let focus_session = if matches!(target, model::BindingTarget::Focus) { self.audio.focused_session().ok().flatten() } else { @@ -1288,6 +1298,11 @@ impl AppState { .map(|settings| settings.enabled) .unwrap_or(true); for target in &targets { + // Integration targets delegate OSD entirely to the plugin via ctx.osd. + // Emitting a default volume_update here would spawn a duplicate card. + if matches!(target, model::BindingTarget::Integration { .. }) { + continue; + } let focus_session = if matches!(target, model::BindingTarget::Focus) { self.audio.focused_session().ok().flatten() } else { @@ -2015,6 +2030,8 @@ fn main() { list_monitors, get_osd_settings, update_osd_settings, + plugin_show_osd, + plugin_hide_osd, get_app_settings, get_app_version, update_app_settings, diff --git a/src/features/osd/osd.js b/src/features/osd/osd.js index 31aa20b..87c503f 100644 --- a/src/features/osd/osd.js +++ b/src/features/osd/osd.js @@ -84,7 +84,7 @@ export function createOsdFeature({ card.appendChild(header); card.appendChild(barDiv); - return { card, iconDiv, labelSpan, valueSpan, fillDiv }; + return { card, iconDiv, labelSpan, valueSpan, fillDiv, barDiv }; } function removeOsdCard(key) { @@ -101,12 +101,15 @@ export function createOsdFeature({ }, 250); } - function showVolumeOsd(target, volume, focusSession) { + function showVolumeOsd(target, volume, focusSession, opts = null) { if (!osd) return; const display = resolveDisplay(target, focusSession); if (!display) return; + const showBar = opts?.showBar !== false; + const showValue = opts?.showValue !== false; + const key = getOsdKey(target); let item = activeOsdCards.get(key); let refs; @@ -142,6 +145,9 @@ export function createOsdFeature({ refs.fillDiv.style.width = `${percent}%`; refs.valueSpan.textContent = `${percent}%`; + refs.barDiv.style.display = showBar ? "" : "none"; + refs.valueSpan.style.display = showValue ? "" : "none"; + if (!osdDebugAlways) { item.timer = setTimeout(() => { removeOsdCard(key); @@ -149,12 +155,15 @@ export function createOsdFeature({ } } - function showMuteOsd(target, muted, focusSession) { + function showMuteOsd(target, muted, focusSession, opts = null) { if (!osd) return; const display = resolveDisplay(target, focusSession); if (!display) return; + const showBar = opts?.showBar !== false; + const showValue = opts?.showValue !== false; + const key = getOsdKey(target); let item = activeOsdCards.get(key); let refs; @@ -185,6 +194,9 @@ export function createOsdFeature({ refs.valueSpan.textContent = muted ? "\ud83d\udd07" : "\ud83d\udd0a"; refs.valueSpan.style.fontSize = "24px"; + refs.barDiv.style.display = showBar ? "" : "none"; + refs.valueSpan.style.display = showValue ? "" : "none"; + if (!osdDebugAlways) { item.timer = setTimeout(() => { removeOsdCard(key); @@ -214,10 +226,14 @@ export function createOsdFeature({ return; } + const opts = {}; + if (typeof payload.show_bar === "boolean") opts.showBar = payload.show_bar; + if (typeof payload.show_value === "boolean") opts.showValue = payload.show_value; + if (payload.action === "toggle_mute") { - showMuteOsd(payload.target, payload.muted, payload.focus_session); + showMuteOsd(payload.target, payload.muted, payload.focus_session, opts); } else { - showVolumeOsd(payload.target, payload.volume, payload.focus_session); + showVolumeOsd(payload.target, payload.volume, payload.focus_session, opts); } } diff --git a/src/main.js b/src/main.js index 7d78a98..d85fa46 100644 --- a/src/main.js +++ b/src/main.js @@ -1255,6 +1255,10 @@ window.__OSD_UPDATE__ = (payload) => { osdFeature?.handleOsdUpdate?.(payload); }; +window.__OSD_HIDE__ = () => { + osdFeature?.hideVolumeOsd?.(); +}; + function closeTargetPanel() { targetsFeature?.closeTargetPanel?.(); } @@ -1599,6 +1603,23 @@ document.addEventListener("pointercancel", () => { async function setupListeners() { + await listen("plugin_osd", (event) => { + let payload = event?.payload; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + return; + } + } + if (!payload || typeof payload !== "object") return; + osdFeature?.handleOsdUpdate?.(payload); + }); + + await listen("plugin_osd_hide", () => { + osdFeature?.hideVolumeOsd?.(); + }); + await listen("bindings_migrated", (event) => { let payload = event.payload; if (typeof payload === "string") { diff --git a/src/plugin_host.js b/src/plugin_host.js index 3b3a1ee..9a11872 100644 --- a/src/plugin_host.js +++ b/src/plugin_host.js @@ -212,6 +212,66 @@ export function createPluginHost({ invoke, listen, onUpdatePluginSettings, onInv }); }, }, + osd: { + showVolume: (target, volume, opts = null) => { + const obj = (opts && typeof opts === "object") ? opts : {}; + const focusSession = obj.focusSession ?? null; + const showBar = typeof obj.showBar === "boolean" ? obj.showBar : null; + const showValue = typeof obj.showValue === "boolean" ? obj.showValue : null; + return invoke("plugin_show_osd", { + target, + action: "Volume", + volume: Number(volume), + muted: null, + focusSession, + showBar, + showValue, + // Compatibility + focus_session: focusSession, + show_bar: showBar, + show_value: showValue, + }); + }, + showMute: (target, muted, opts = null) => { + const obj = (opts && typeof opts === "object") ? opts : {}; + const focusSession = obj.focusSession ?? null; + const showBar = typeof obj.showBar === "boolean" ? obj.showBar : null; + const showValue = typeof obj.showValue === "boolean" ? obj.showValue : null; + return invoke("plugin_show_osd", { + target, + action: "ToggleMute", + volume: null, + muted: Boolean(muted), + focusSession, + showBar, + showValue, + // Compatibility + focus_session: focusSession, + show_bar: showBar, + show_value: showValue, + }); + }, + hide: () => invoke("plugin_hide_osd"), + getSettings: () => invoke("get_osd_settings"), + onSettingsChanged: (handler) => { + if (typeof handler !== "function") return () => { }; + let active = true; + const unlistenPromise = listen("osd_settings_changed", (event) => { + if (!active) return; + let payload = event?.payload; + if (typeof payload === "string") { + try { payload = JSON.parse(payload); } catch { payload = null; } + } + try { handler(payload); } catch (e) { } + }); + return () => { + active = false; + Promise.resolve(unlistenPromise).then((off) => { + try { if (typeof off === "function") off(); } catch { } + }); + }; + }, + }, ws: { open: (url, headers = {}, connectTimeoutMs = 500) => invoke("ws_open", { url,