diff --git a/InputSimulatorPlus/WindowsInput/LinuxInputBridge.cs b/InputSimulatorPlus/WindowsInput/LinuxInputBridge.cs
new file mode 100644
index 0000000..609b358
--- /dev/null
+++ b/InputSimulatorPlus/WindowsInput/LinuxInputBridge.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using WindowsInput.Native;
+
+namespace WindowsInput
+{
+ ///
+ /// Receives arrays built for Win32 SendInput and
+ /// forwards them through the Linux ydotool CLI. Used only
+ /// when the host process is running under Wine
+ /// (see ).
+ ///
+ /// Strategy: bucket all keyboard events from one DispatchInput call
+ /// into a SINGLE ydotool key … invocation so a 4-key combo
+ /// costs one fork+exec instead of four. Mouse clicks and wheel
+ /// events are emitted separately because they use different
+ /// ydotool verbs.
+ ///
+ internal static class LinuxInputBridge
+ {
+ // Wine maps `/` to `Z:\`, so Linux absolute paths become Z: paths.
+ private static readonly string YdotoolPath = ResolveYdotool();
+ private static readonly string YdotoolSocket = ResolveSocketPath();
+
+ // Surface unknown VK and scan codes once per process. The reader of
+ // these logs will see them via Wine's stderr aggregated in the
+ // OpenDeck plugin log file.
+ private static readonly HashSet _unknownVks = new HashSet();
+ private static readonly HashSet _unknownScans = new HashSet();
+ private static readonly object _unknownLock = new object();
+
+ public static void DispatchInput(INPUT[] inputs)
+ {
+ if (inputs == null || inputs.Length == 0) return;
+
+ // Bucket keyboard events into one ydotool invocation.
+ var keyArgs = new List(inputs.Length);
+ // Mouse events run sequentially (typically 1 per call anyway).
+ var mouseCmds = new List<(string verb, string args)>();
+
+ foreach (var inp in inputs)
+ {
+ if (inp.Type == (uint)InputType.Keyboard)
+ {
+ AccumulateKeyboard(inp.Data.Keyboard, keyArgs);
+ }
+ else if (inp.Type == (uint)InputType.Mouse)
+ {
+ AccumulateMouse(inp.Data.Mouse, mouseCmds);
+ }
+ // Hardware input (InputType.Hardware) is ignored — SCStreamDeck
+ // doesn't synthesize HW events.
+ }
+
+ if (keyArgs.Count > 0)
+ {
+ Run("key", string.Join(" ", keyArgs));
+ }
+ foreach (var (verb, args) in mouseCmds)
+ {
+ Run(verb, args);
+ }
+ }
+
+ private static void AccumulateKeyboard(KEYBDINPUT k, List args)
+ {
+ bool isUp = (k.Flags & (uint)KeyboardFlag.KeyUp) != 0;
+ bool isScan = (k.Flags & (uint)KeyboardFlag.ScanCode) != 0;
+ bool isExt = (k.Flags & (uint)KeyboardFlag.ExtendedKey) != 0;
+
+ // KEYEVENTF_UNICODE is rare here (SCStreamDeck targets game
+ // keybinds, not text input). If we ever see one, fall back
+ // to `ydotool type` for the single char.
+ if ((k.Flags & (uint)KeyboardFlag.Unicode) != 0)
+ {
+ if (!isUp && k.Scan != 0)
+ {
+ var s = char.ConvertFromUtf32(k.Scan);
+ Run("type", "--key-delay=0 -- " + EscapeShell(s));
+ }
+ return;
+ }
+
+ int evdev;
+
+ // SCStreamDeck builds inputs with KEYEVENTF_SCANCODE: the real
+ // key identity is in `Scan` (a DirectInput DIK_*/PS/2 Set 1
+ // scan code), and `KeyCode`(Vk) is 0. Translate via the scan-code
+ // path. Also try the scan path as a fallback whenever Vk is 0
+ // but Scan is populated, even if the SCANCODE flag wasn't set.
+ if (isScan || (k.KeyCode == 0 && k.Scan != 0))
+ {
+ if (Vk2Evdev.TryFromScan(k.Scan, isExt, out evdev))
+ {
+ args.Add(evdev + ":" + (isUp ? "0" : "1"));
+ return;
+ }
+ RecordUnknownScan(k.Scan, isExt);
+ return;
+ }
+
+ int vk = k.KeyCode;
+ if (Vk2Evdev.TryGet(vk, out evdev))
+ {
+ args.Add(evdev + ":" + (isUp ? "0" : "1"));
+ }
+ else
+ {
+ RecordUnknownVk(vk);
+ }
+ }
+
+ private static void AccumulateMouse(MOUSEINPUT m, List<(string, string)> cmds)
+ {
+ uint f = m.Flags;
+
+ // Wheel / horizontal wheel — `mouseData` is a signed int as wheel delta.
+ // ydotool's wheel API uses `mousemove --wheel -- 0 ` (vertical)
+ // or with --xwheel for horizontal (depending on ydotool version).
+ if ((f & (uint)MouseFlag.VerticalWheel) != 0)
+ {
+ int delta = unchecked((int)m.MouseData);
+ cmds.Add(("mousemove", "--wheel -- 0 " + delta));
+ return;
+ }
+ if ((f & (uint)MouseFlag.HorizontalWheel) != 0)
+ {
+ int delta = unchecked((int)m.MouseData);
+ cmds.Add(("mousemove", "--wheel -- " + delta + " 0"));
+ return;
+ }
+
+ // Buttons. ydotool encodes click commands as hex bitmask:
+ // high nibble: 0x4 = press, 0x8 = release, 0xC = press+release
+ // low nibble : 0x0 = left, 0x1 = right, 0x2 = middle, 0x3/0x4 = side
+ // We do the simplest faithful thing: separate down/up if they
+ // arrive separately, atomic press+release when both flags are
+ // set in the same INPUT (which is what SendInput-style users
+ // usually do for simple clicks).
+ int btn = -1;
+ bool down = false, up = false;
+ if ((f & (uint)MouseFlag.LeftDown) != 0) { btn = 0; down = true; }
+ else if ((f & (uint)MouseFlag.LeftUp) != 0) { btn = 0; up = true; }
+ else if ((f & (uint)MouseFlag.RightDown) != 0) { btn = 1; down = true; }
+ else if ((f & (uint)MouseFlag.RightUp) != 0) { btn = 1; up = true; }
+ else if ((f & (uint)MouseFlag.MiddleDown) != 0) { btn = 2; down = true; }
+ else if ((f & (uint)MouseFlag.MiddleUp) != 0) { btn = 2; up = true; }
+ else if ((f & (uint)MouseFlag.XDown) != 0) { btn = 3 + (int)(m.MouseData - 1); down = true; }
+ else if ((f & (uint)MouseFlag.XUp) != 0) { btn = 3 + (int)(m.MouseData - 1); up = true; }
+
+ if (btn < 0) return;
+
+ int hi = (down && up) ? 0xC : (down ? 0x4 : (up ? 0x8 : 0));
+ if (hi == 0) return;
+ int code = (hi << 4) | (btn & 0x0F);
+ cmds.Add(("click", "0x" + code.ToString("X2")));
+ }
+
+ private static void Run(string verb, string args)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = YdotoolPath,
+ Arguments = verb + " " + args,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ };
+ psi.EnvironmentVariables["YDOTOOL_SOCKET"] = YdotoolSocket;
+ using (var p = Process.Start(psi))
+ {
+ if (p != null)
+ {
+ // 500 ms is a generous guard — ydotool typically returns in 2-5 ms.
+ p.WaitForExit(500);
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine("[LinuxInputBridge] ydotool spawn failed: " + e.Message);
+ }
+ }
+
+ private static string ResolveYdotool()
+ {
+ // Override via env var for non-standard installs.
+ string fromEnv = Environment.GetEnvironmentVariable("YDOTOOL_PATH");
+ if (!string.IsNullOrEmpty(fromEnv))
+ {
+ if (fromEnv.StartsWith("/")) fromEnv = "Z:" + fromEnv.Replace("/", "\\");
+ if (File.Exists(fromEnv)) return fromEnv;
+ }
+ foreach (var p in new[]
+ {
+ @"Z:\usr\bin\ydotool",
+ @"Z:\usr\local\bin\ydotool",
+ })
+ {
+ if (File.Exists(p)) return p;
+ }
+ // Last-resort guess; Process.Start will surface the failure clearly.
+ return @"Z:\usr\bin\ydotool";
+ }
+
+ private static string ResolveSocketPath()
+ {
+ // ydotool defaults to $XDG_RUNTIME_DIR/.ydotool_socket on modern
+ // systemd-user setups (CachyOS/Arch/Fedora), falls back to
+ // /tmp/.ydotool_socket on older configurations.
+ //
+ // Wine exposes Linux env vars via /proc/self/environ which is
+ // reachable as Z:\proc\self\environ. We read it once at startup
+ // to find XDG_RUNTIME_DIR.
+ try
+ {
+ byte[] bytes = File.ReadAllBytes(@"Z:\proc\self\environ");
+ string text = Encoding.UTF8.GetString(bytes);
+ foreach (var entry in text.Split('\0'))
+ {
+ const string prefix = "XDG_RUNTIME_DIR=";
+ if (entry.StartsWith(prefix))
+ {
+ string unixDir = entry.Substring(prefix.Length);
+ if (!string.IsNullOrEmpty(unixDir))
+ {
+ return "Z:" + (unixDir + "/.ydotool_socket").Replace("/", "\\");
+ }
+ }
+ }
+ }
+ catch { }
+ return @"Z:\tmp\.ydotool_socket";
+ }
+
+ private static void RecordUnknownVk(int vk)
+ {
+ lock (_unknownLock)
+ {
+ if (_unknownVks.Add(vk))
+ {
+ Console.Error.WriteLine(
+ "[LinuxInputBridge] unknown VK 0x" + vk.ToString("X2") +
+ " — no evdev mapping. File a Vk2Evdev addition.");
+ }
+ }
+ }
+
+ private static void RecordUnknownScan(int scan, bool extended)
+ {
+ long key = ((long)(extended ? 1 : 0) << 32) | (uint)scan;
+ lock (_unknownLock)
+ {
+ if (_unknownScans.Add(key))
+ {
+ Console.Error.WriteLine(
+ "[LinuxInputBridge] unknown SCAN 0x" + scan.ToString("X2") +
+ (extended ? " (extended)" : "") +
+ " — no evdev mapping. File a Vk2Evdev.TryFromScan addition.");
+ }
+ }
+ }
+
+ private static string EscapeShell(string s)
+ {
+ // ydotool type accepts the string after `--`. We still need to avoid
+ // double-quotes breaking out of our single-argument string.
+ return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
+ }
+ }
+}
diff --git a/InputSimulatorPlus/WindowsInput/Native/Vk2Evdev.cs b/InputSimulatorPlus/WindowsInput/Native/Vk2Evdev.cs
new file mode 100644
index 0000000..1fd99b4
--- /dev/null
+++ b/InputSimulatorPlus/WindowsInput/Native/Vk2Evdev.cs
@@ -0,0 +1,217 @@
+using System.Collections.Generic;
+
+namespace WindowsInput.Native
+{
+ ///
+ /// Maps Win32 Virtual-Key codes (VK_*) to Linux evdev keycodes
+ /// (KEY_* from /usr/include/linux/input-event-codes.h).
+ ///
+ /// Used by to translate
+ /// arrays into ydotool key arguments when
+ /// the process is running under Wine on Linux.
+ ///
+ /// Covers the keyboard keys SCStreamDeck actually emits (alpha-num,
+ /// punctuation, function keys, modifiers, navigation cluster, numpad).
+ /// Missing entries fall through to returning false,
+ /// which the bridge surfaces as a deduplicated log line.
+ ///
+ internal static class Vk2Evdev
+ {
+ private static readonly Dictionary Map = new Dictionary
+ {
+ // ── Modifiers ──────────────────────────────────────────
+ // Generic VKs: VK_SHIFT/CONTROL/MENU map to left-side by default.
+ // Win32 SendInput typically receives the L/R variants (0xA0-0xA5).
+ { 0x10, 42 }, // VK_SHIFT → KEY_LEFTSHIFT
+ { 0xA0, 42 }, // VK_LSHIFT → KEY_LEFTSHIFT
+ { 0xA1, 54 }, // VK_RSHIFT → KEY_RIGHTSHIFT
+ { 0x11, 29 }, // VK_CONTROL → KEY_LEFTCTRL
+ { 0xA2, 29 }, // VK_LCONTROL → KEY_LEFTCTRL
+ { 0xA3, 97 }, // VK_RCONTROL → KEY_RIGHTCTRL
+ { 0x12, 56 }, // VK_MENU (alt) → KEY_LEFTALT
+ { 0xA4, 56 }, // VK_LMENU → KEY_LEFTALT
+ { 0xA5, 100 }, // VK_RMENU → KEY_RIGHTALT
+ { 0x5B, 125 }, // VK_LWIN → KEY_LEFTMETA
+ { 0x5C, 126 }, // VK_RWIN → KEY_RIGHTMETA
+ { 0x14, 58 }, // VK_CAPITAL → KEY_CAPSLOCK
+
+ // ── Whitespace / control ────────────────────────────────
+ { 0x08, 14 }, // VK_BACK → KEY_BACKSPACE
+ { 0x09, 15 }, // VK_TAB → KEY_TAB
+ { 0x0D, 28 }, // VK_RETURN → KEY_ENTER
+ { 0x13, 119 }, // VK_PAUSE → KEY_PAUSE
+ { 0x1B, 1 }, // VK_ESCAPE → KEY_ESC
+ { 0x20, 57 }, // VK_SPACE → KEY_SPACE
+
+ // ── Navigation ──────────────────────────────────────────
+ { 0x21, 104 }, // VK_PRIOR (PgUp)→ KEY_PAGEUP
+ { 0x22, 109 }, // VK_NEXT (PgDn) → KEY_PAGEDOWN
+ { 0x23, 107 }, // VK_END → KEY_END
+ { 0x24, 102 }, // VK_HOME → KEY_HOME
+ { 0x25, 105 }, // VK_LEFT → KEY_LEFT
+ { 0x26, 103 }, // VK_UP → KEY_UP
+ { 0x27, 106 }, // VK_RIGHT → KEY_RIGHT
+ { 0x28, 108 }, // VK_DOWN → KEY_DOWN
+ { 0x2C, 99 }, // VK_SNAPSHOT → KEY_SYSRQ
+ { 0x2D, 110 }, // VK_INSERT → KEY_INSERT
+ { 0x2E, 111 }, // VK_DELETE → KEY_DELETE
+
+ // ── Top-row digits ──────────────────────────────────────
+ { 0x30, 11 }, // 0
+ { 0x31, 2 }, // 1
+ { 0x32, 3 },
+ { 0x33, 4 },
+ { 0x34, 5 },
+ { 0x35, 6 },
+ { 0x36, 7 },
+ { 0x37, 8 },
+ { 0x38, 9 },
+ { 0x39, 10 }, // 9
+
+ // ── Letters ─────────────────────────────────────────────
+ { 0x41, 30 }, // A
+ { 0x42, 48 }, // B
+ { 0x43, 46 }, // C
+ { 0x44, 32 }, // D
+ { 0x45, 18 }, // E
+ { 0x46, 33 }, // F
+ { 0x47, 34 }, // G
+ { 0x48, 35 }, // H
+ { 0x49, 23 }, // I
+ { 0x4A, 36 }, // J
+ { 0x4B, 37 }, // K
+ { 0x4C, 38 }, // L
+ { 0x4D, 50 }, // M
+ { 0x4E, 49 }, // N
+ { 0x4F, 24 }, // O
+ { 0x50, 25 }, // P
+ { 0x51, 16 }, // Q
+ { 0x52, 19 }, // R
+ { 0x53, 31 }, // S
+ { 0x54, 20 }, // T
+ { 0x55, 22 }, // U
+ { 0x56, 47 }, // V
+ { 0x57, 17 }, // W
+ { 0x58, 45 }, // X
+ { 0x59, 21 }, // Y
+ { 0x5A, 44 }, // Z
+
+ // ── Numpad ──────────────────────────────────────────────
+ { 0x60, 82 }, // VK_NUMPAD0 → KEY_KP0
+ { 0x61, 79 }, // VK_NUMPAD1 → KEY_KP1
+ { 0x62, 80 },
+ { 0x63, 81 },
+ { 0x64, 75 },
+ { 0x65, 76 },
+ { 0x66, 77 },
+ { 0x67, 71 },
+ { 0x68, 72 },
+ { 0x69, 73 }, // VK_NUMPAD9 → KEY_KP9
+ { 0x6A, 55 }, // VK_MULTIPLY → KEY_KPASTERISK
+ { 0x6B, 78 }, // VK_ADD → KEY_KPPLUS
+ { 0x6D, 74 }, // VK_SUBTRACT → KEY_KPMINUS
+ { 0x6E, 83 }, // VK_DECIMAL → KEY_KPDOT
+ { 0x6F, 98 }, // VK_DIVIDE → KEY_KPSLASH
+ { 0x90, 69 }, // VK_NUMLOCK → KEY_NUMLOCK
+ { 0x91, 70 }, // VK_SCROLL → KEY_SCROLLLOCK
+
+ // ── Function keys ───────────────────────────────────────
+ { 0x70, 59 }, // F1
+ { 0x71, 60 },
+ { 0x72, 61 },
+ { 0x73, 62 },
+ { 0x74, 63 },
+ { 0x75, 64 },
+ { 0x76, 65 },
+ { 0x77, 66 },
+ { 0x78, 67 },
+ { 0x79, 68 },
+ { 0x7A, 87 }, // F11
+ { 0x7B, 88 }, // F12
+ { 0x7C, 183 }, // F13
+ { 0x7D, 184 }, // F14
+ { 0x7E, 185 }, // F15
+ { 0x7F, 186 },
+ { 0x80, 187 },
+ { 0x81, 188 },
+ { 0x82, 189 },
+ { 0x83, 190 },
+ { 0x84, 191 },
+ { 0x85, 192 },
+ { 0x86, 193 },
+ { 0x87, 194 }, // F24
+
+ // ── OEM punctuation (US layout assumptions; SC keybinds ─
+ // typically reference the unshifted key) ──────────────
+ { 0xBA, 39 }, // VK_OEM_1 ;: → KEY_SEMICOLON
+ { 0xBB, 13 }, // VK_OEM_PLUS =+ → KEY_EQUAL
+ { 0xBC, 51 }, // VK_OEM_COMMA ,< → KEY_COMMA
+ { 0xBD, 12 }, // VK_OEM_MINUS -_ → KEY_MINUS
+ { 0xBE, 52 }, // VK_OEM_PERIOD .> → KEY_DOT
+ { 0xBF, 53 }, // VK_OEM_2 /? → KEY_SLASH
+ { 0xC0, 41 }, // VK_OEM_3 `~ → KEY_GRAVE
+ { 0xDB, 26 }, // VK_OEM_4 [{ → KEY_LEFTBRACE
+ { 0xDC, 43 }, // VK_OEM_5 \| → KEY_BACKSLASH
+ { 0xDD, 27 }, // VK_OEM_6 ]} → KEY_RIGHTBRACE
+ { 0xDE, 40 }, // VK_OEM_7 '" → KEY_APOSTROPHE
+ { 0xE2, 86 }, // VK_OEM_102 <> → KEY_102ND (ISO key)
+ };
+
+ public static bool TryGet(int vk, out int evdev)
+ {
+ return Map.TryGetValue(vk, out evdev);
+ }
+
+ ///
+ /// Map a PS/2 Set 1 scan code (a.k.a. DirectInput DIK_* code)
+ /// to a Linux evdev keycode.
+ ///
+ /// For non-extended keys (no ),
+ /// the PS/2 Set 1 scan codes 0x01–0x59 align numerically with
+ /// evdev keycodes 1–89, so a pass-through is correct for the bulk
+ /// of the main keyboard (letters, digits, function row, modifiers,
+ /// punctuation, numpad). For extended keys (Set 1 0xE0-prefix in
+ /// hardware → reported with the EXTENDED flag in ),
+ /// the scan code itself is non-unique (e.g. 0x1D is both LCTRL
+ /// and RCTRL depending on the prefix); a small explicit table
+ /// handles those.
+ ///
+ public static bool TryFromScan(int scan, bool extended, out int evdev)
+ {
+ if (extended)
+ {
+ switch (scan)
+ {
+ case 0x1C: evdev = 96; return true; // numpad enter
+ case 0x1D: evdev = 97; return true; // right ctrl
+ case 0x35: evdev = 98; return true; // numpad /
+ case 0x37: evdev = 99; return true; // print screen
+ case 0x38: evdev = 100; return true; // right alt
+ case 0x46: evdev = 119; return true; // ctrl+break / pause
+ case 0x47: evdev = 102; return true; // home
+ case 0x48: evdev = 103; return true; // up
+ case 0x49: evdev = 104; return true; // page up
+ case 0x4B: evdev = 105; return true; // left
+ case 0x4D: evdev = 106; return true; // right
+ case 0x4F: evdev = 107; return true; // end
+ case 0x50: evdev = 108; return true; // down
+ case 0x51: evdev = 109; return true; // page down
+ case 0x52: evdev = 110; return true; // insert
+ case 0x53: evdev = 111; return true; // delete
+ case 0x5B: evdev = 125; return true; // left win
+ case 0x5C: evdev = 126; return true; // right win
+ case 0x5D: evdev = 127; return true; // apps / menu
+ default: evdev = 0; return false;
+ }
+ }
+ // Non-extended: identity for the canonical range.
+ if (scan > 0 && scan <= 0x59)
+ {
+ evdev = scan;
+ return true;
+ }
+ evdev = 0;
+ return false;
+ }
+ }
+}
diff --git a/InputSimulatorPlus/WindowsInput/WindowsInput.csproj b/InputSimulatorPlus/WindowsInput/WindowsInput.csproj
index 02265c0..d7c0876 100644
--- a/InputSimulatorPlus/WindowsInput/WindowsInput.csproj
+++ b/InputSimulatorPlus/WindowsInput/WindowsInput.csproj
@@ -75,6 +75,9 @@
+
+
+
diff --git a/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs b/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs
index 7a799f7..1576a0c 100644
--- a/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs
+++ b/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs
@@ -29,6 +29,16 @@ public void DispatchInput(INPUT[] inputs)
throw new ArgumentException("The input array was empty", "inputs");
}
+ // When running under Wine, Win32 SendInput cannot reach native
+ // Linux applications (e.g. Star Citizen via Proton). Route through
+ // ydotool instead. The check is cached so the overhead is one
+ // boolean read on every subsequent call.
+ if (WineDetector.IsWine)
+ {
+ LinuxInputBridge.DispatchInput(inputs);
+ return;
+ }
+
uint successful = NativeMethods.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT)));
if (successful != inputs.Length)
{
diff --git a/InputSimulatorPlus/WindowsInput/WineDetector.cs b/InputSimulatorPlus/WindowsInput/WineDetector.cs
new file mode 100644
index 0000000..0166626
--- /dev/null
+++ b/InputSimulatorPlus/WindowsInput/WineDetector.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace WindowsInput
+{
+ ///
+ /// Runtime check for whether the process is running on top of Wine (a
+ /// Linux/macOS Win32 translation layer). The standard way is to look up
+ /// the wine_get_version export of ntdll.dll — present
+ /// under Wine, absent on native Windows.
+ ///
+ internal static class WineDetector
+ {
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
+ private static extern IntPtr LoadLibrary(string lpFileName);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
+ private static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
+
+ private static readonly Lazy _isWine = new Lazy(() =>
+ {
+ try
+ {
+ IntPtr ntdll = LoadLibrary("ntdll.dll");
+ return ntdll != IntPtr.Zero
+ && GetProcAddress(ntdll, "wine_get_version") != IntPtr.Zero;
+ }
+ catch
+ {
+ return false;
+ }
+ });
+
+ public static bool IsWine => _isWine.Value;
+ }
+}
diff --git a/PluginCore/ActionKeys/ControlPanelKey.cs b/PluginCore/ActionKeys/ControlPanelKey.cs
index a7084be..c7a3a97 100644
--- a/PluginCore/ActionKeys/ControlPanelKey.cs
+++ b/PluginCore/ActionKeys/ControlPanelKey.cs
@@ -26,6 +26,7 @@ public sealed class ControlPanelKey : KeyAndEncoderBase
private const string PiEventForceRedetection = "forceRedetection";
private const string PiEventFactoryReset = "factoryReset";
private const string PiEventSetDataP4KOverride = "setDataP4KOverride";
+ private const string PiEventRequestFilePicker = "requestFilePicker";
[ExcludeFromCodeCoverage]
public ControlPanelKey(SDConnection connection, InitialPayload payload) : base(connection, payload)
@@ -132,6 +133,9 @@ private void OnSendToPlugin(object? sender, SDEventReceivedEventArgs
- {
await InitializationService.ApplyCustomDataP4KOverrideAsync(channel, dataP4KPath)
- .ConfigureAwait(false);
+ .ConfigureAwait(false));
+ }
+
+ private void HandleRequestFilePicker(JObject payload)
+ {
+ string? pickerId = payload.Value("pickerId");
+ string title = payload.Value("title") ?? "Select file";
+ string filter = payload.Value("filter") ?? "All files|*.*";
+
+ RunBackground(async () =>
+ {
+ string? path = await Common.WineFileDialog.ShowOpenFileDialogAsync(title, filter).ConfigureAwait(false);
+ await Connection.SendToPropertyInspectorAsync(new JObject
+ {
+ ["event"] = "filePickerResult",
+ ["pickerId"] = pickerId,
+ ["path"] = path,
+ }).ConfigureAwait(false);
});
}
diff --git a/PluginCore/ActionKeys/SCActionBase.cs b/PluginCore/ActionKeys/SCActionBase.cs
index fdc7133..8e4ce51 100644
--- a/PluginCore/ActionKeys/SCActionBase.cs
+++ b/PluginCore/ActionKeys/SCActionBase.cs
@@ -215,6 +215,10 @@ private void OnSendToPlugin(object? sender, SDEventReceivedEventArgs("pickerId");
+ string title = payload.Value("title") ?? "Select file";
+ string filter = payload.Value("filter") ?? "All files|*.*";
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ string? path = await Common.WineFileDialog.ShowOpenFileDialogAsync(title, filter).ConfigureAwait(false);
+ await Connection.SendToPropertyInspectorAsync(new JObject
+ {
+ ["event"] = "filePickerResult",
+ ["pickerId"] = pickerId,
+ ["path"] = path,
+ }).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Log.Err($"[{GetType().Name}] filePicker failed: {ex.Message}", ex);
+ }
+ });
+ }
+
#endregion
#region Lifecycle Methods
diff --git a/PluginCore/Common/DirectInputDisplayMapper.cs b/PluginCore/Common/DirectInputDisplayMapper.cs
index fad0d2c..f0eab23 100644
--- a/PluginCore/Common/DirectInputDisplayMapper.cs
+++ b/PluginCore/Common/DirectInputDisplayMapper.cs
@@ -8,6 +8,8 @@ namespace SCStreamDeck.Common;
///
internal static class DirectInputDisplayMapper
{
+ private static readonly char[] PartSeparators = { '+' };
+
#region Public Interface
///
@@ -125,7 +127,7 @@ private static bool TryGetFixedDisplay(DirectInputKeyCode dik, out string displa
/// Parses binding into token parts.
///
private static string[] ParseBindingParts(string scKeyboardBind) =>
- scKeyboardBind.Split(['+'], StringSplitOptions.TrimEntries);
+ scKeyboardBind.Split(PartSeparators, StringSplitOptions.TrimEntries);
///
/// Formats display by converting tokens and joining.
diff --git a/PluginCore/Common/WineFileDialog.cs b/PluginCore/Common/WineFileDialog.cs
new file mode 100644
index 0000000..10473f7
--- /dev/null
+++ b/PluginCore/Common/WineFileDialog.cs
@@ -0,0 +1,222 @@
+using System.Runtime.InteropServices;
+
+namespace SCStreamDeck.Common;
+
+///
+/// Wraps the modern Win32 Common Item Dialog (IFileOpenDialog)
+/// so the plugin can present its own native file picker. Necessary
+/// because OpenDeck's Property Inspector runs inside a Tauri WebView
+/// that doesn't grant dialog:* capabilities to plugin windows,
+/// and plain HTML <input type="file"> in that WebView
+/// only reveals the basename, not the full path.
+///
+/// Under Wine on Linux, comdlg32's IFileOpenDialog
+/// implementation has been mature for years
+/// (dlls/comdlg32/itemdlg.c) and renders as a native dialog,
+/// so the user sees a familiar Linux file chooser and the returned
+/// path is Wine-resolvable (typically Z:\… for Unix files).
+///
+/// On native Windows the COM dialog is the recommended API since
+/// Vista (2007) and gives the modern shell-style dialog.
+///
+/// The dialog is modal and the COM apartment must be STA, so callers
+/// should invoke which spawns a
+/// dedicated STA worker.
+///
+internal static class WineFileDialog
+{
+ // FILEOPENDIALOGOPTIONS — values from shobjidl_core.h
+ private const uint FOS_NOCHANGEDIR = 0x00000008;
+ private const uint FOS_PATHMUSTEXIST = 0x00000800;
+ private const uint FOS_FILEMUSTEXIST = 0x00001000;
+ private const uint FOS_FORCEFILESYSTEM = 0x00000040;
+
+ // SIGDN — SIGDN_FILESYSPATH gives us a normal "X:\foo\bar.ext" path
+ // (the only SIGDN guaranteed to map to a real filesystem location).
+ private const uint SIGDN_FILESYSPATH = 0x80058000;
+
+ private static readonly Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7");
+
+ [DllImport("ole32.dll")]
+ private static extern int CoCreateInstance(
+ in Guid rclsid,
+ nint pUnkOuter,
+ uint dwClsContext,
+ in Guid riid,
+ out nint ppv);
+
+ private const uint CLSCTX_INPROC_SERVER = 0x1;
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct COMDLG_FILTERSPEC
+ {
+ [MarshalAs(UnmanagedType.LPWStr)] public string pszName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string pszSpec;
+ }
+
+ [ComImport]
+ [Guid("b4db1657-70d7-485e-8e3e-6fcb5a5c1802")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IModalWindow
+ {
+ [PreserveSig]
+ int Show(nint hwndParent);
+ }
+
+ [ComImport]
+ [Guid("42f85136-db7e-439c-85f1-e4075d135fc8")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IFileDialog
+ {
+ // IModalWindow
+ [PreserveSig] int Show(nint hwndParent);
+ // IFileDialog
+ void SetFileTypes(uint cFileTypes, [MarshalAs(UnmanagedType.LPArray)] COMDLG_FILTERSPEC[] rgFilterSpec);
+ void SetFileTypeIndex(uint iFileType);
+ void GetFileTypeIndex(out uint piFileType);
+ void Advise(nint pfde, out uint pdwCookie);
+ void Unadvise(uint dwCookie);
+ void SetOptions(uint fos);
+ void GetOptions(out uint pfos);
+ void SetDefaultFolder(IShellItem psi);
+ void SetFolder(IShellItem psi);
+ void GetFolder(out IShellItem ppsi);
+ void GetCurrentSelection(out IShellItem ppsi);
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
+ void GetResult(out IShellItem ppsi);
+ void AddPlace(IShellItem psi, int fdap);
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
+ void Close(int hr);
+ void SetClientGuid(in Guid guid);
+ void ClearClientData();
+ void SetFilter(nint pFilter);
+ }
+
+ [ComImport]
+ [Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IFileOpenDialog : IFileDialog
+ {
+ // (IFileOpenDialog extends IFileDialog; we don't need the
+ // multi-select / current-results methods so they are omitted.)
+ }
+
+ [ComImport]
+ [Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IShellItem
+ {
+ void BindToHandler(nint pbc, in Guid bhid, in Guid riid, out nint ppv);
+ void GetParent(out IShellItem ppsi);
+ void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
+ void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
+ void Compare(IShellItem psi, uint hint, out int piOrder);
+ }
+
+ public static Task ShowOpenFileDialogAsync(string title, string filter)
+ {
+ TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Thread t = new(() =>
+ {
+ try
+ {
+ tcs.SetResult(ShowOpenFileDialog(title, filter));
+ }
+ catch (Exception ex)
+ {
+ tcs.SetException(ex);
+ }
+ });
+ // IFileOpenDialog uses COM internally and requires STA.
+ t.SetApartmentState(ApartmentState.STA);
+ t.IsBackground = true;
+ t.Name = "WineFileDialog";
+ t.Start();
+ return tcs.Task;
+ }
+
+ private static string? ShowOpenFileDialog(string title, string filter)
+ {
+ Guid clsid = CLSID_FileOpenDialog;
+ Guid iid = typeof(IFileOpenDialog).GUID;
+
+ int hr = CoCreateInstance(in clsid, nint.Zero, CLSCTX_INPROC_SERVER, in iid, out nint pDialog);
+ if (hr != 0 || pDialog == nint.Zero)
+ {
+ return null;
+ }
+
+ IFileOpenDialog dialog = (IFileOpenDialog)Marshal.GetObjectForIUnknown(pDialog);
+ Marshal.Release(pDialog);
+
+ try
+ {
+ if (!string.IsNullOrEmpty(title))
+ {
+ dialog.SetTitle(title);
+ }
+
+ COMDLG_FILTERSPEC[] specs = ParseFilter(filter);
+ if (specs.Length > 0)
+ {
+ dialog.SetFileTypes((uint)specs.Length, specs);
+ dialog.SetFileTypeIndex(1); // 1-based; default to first filter
+ }
+
+ dialog.SetOptions(FOS_FILEMUSTEXIST | FOS_PATHMUSTEXIST | FOS_NOCHANGEDIR | FOS_FORCEFILESYSTEM);
+
+ // Show with no parent window — the dialog manages itself.
+ // S_OK = picked, anything else (incl. HRESULT_FROM_WIN32(ERROR_CANCELLED) = 0x800704C7) = cancel/error.
+ int showHr = dialog.Show(nint.Zero);
+ if (showHr != 0)
+ {
+ return null;
+ }
+
+ dialog.GetResult(out IShellItem item);
+ try
+ {
+ item.GetDisplayName(SIGDN_FILESYSPATH, out string path);
+ return path;
+ }
+ finally
+ {
+ Marshal.ReleaseComObject(item);
+ }
+ }
+ finally
+ {
+ Marshal.ReleaseComObject(dialog);
+ }
+ }
+
+ // Filter input: pipe-separated pairs, e.g. "Data.p4k|*.p4k|All files|*.*".
+ // IFileDialog wants COMDLG_FILTERSPEC[]{ {pszName, pszSpec}, ... } with
+ // multiple patterns inside a single pszSpec separated by ';'.
+ private static COMDLG_FILTERSPEC[] ParseFilter(string filter)
+ {
+ if (string.IsNullOrEmpty(filter))
+ {
+ return [new COMDLG_FILTERSPEC { pszName = "All files", pszSpec = "*.*" }];
+ }
+
+ string[] parts = filter.Split('|');
+ int pairs = parts.Length / 2;
+ if (pairs == 0)
+ {
+ return [new COMDLG_FILTERSPEC { pszName = "All files", pszSpec = "*.*" }];
+ }
+
+ var result = new COMDLG_FILTERSPEC[pairs];
+ for (int i = 0; i < pairs; i++)
+ {
+ result[i].pszName = parts[i * 2];
+ result[i].pszSpec = parts[i * 2 + 1];
+ }
+ return result;
+ }
+}
diff --git a/PluginCore/PluginCore.csproj b/PluginCore/PluginCore.csproj
index 6db509a..18a856c 100644
--- a/PluginCore/PluginCore.csproj
+++ b/PluginCore/PluginCore.csproj
@@ -3,6 +3,9 @@
Exe
net8.0-windows
+
+ true
true
12
com.jarex985.scstreamdeck
@@ -72,7 +75,7 @@
-
+
diff --git a/PluginCore/PropertyInspector/js/sc-components.js b/PluginCore/PropertyInspector/js/sc-components.js
index a33b554..b625e02 100644
--- a/PluginCore/PropertyInspector/js/sc-components.js
+++ b/PluginCore/PropertyInspector/js/sc-components.js
@@ -1,2 +1,2 @@
/* generated: sc-components.js */
-(function(){function r(n,t){let i;return function(...r){const u=()=>{clearTimeout(i),n(...r)};clearTimeout(i);i=setTimeout(u,t)}}function u(n){document.readyState==="loading"?document.addEventListener("DOMContentLoaded",n):n()}function f(t,i={}){try{if(!n.SDPIComponents?.streamDeckClient?.send){console.warn("[sc-common] SDPIComponents.streamDeckClient.send not available");return}n.SDPIComponents.streamDeckClient.send("sendToPlugin",{event:t,...i})}catch(r){console.error("[sc-common] sendToPlugin failed",r)}}const n=globalThis,i=n.SCPI=n.SCPI||{},t=i.util=i.util||{};t.debounce=r;t.onDocumentReady=u;t.sendToPlugin=f;typeof n.debounce!="function"&&(n.debounce=r);typeof n.onDocumentReady!="function"&&(n.onDocumentReady=u);typeof n.sendToPlugin!="function"&&(n.sendToPlugin=f)})(),function(){function e(){return n.SDPIComponents?.streamDeckClient||null}function i(n,t={}){const i=e();if(!i?.send){console.warn("[sc-bus] SDPI streamDeckClient.send not available");return}i.send("sendToPlugin",{event:n,...t})}function s(n,t,u={}){const f=String(n||"").trim();if(!f){i(t,u);return}r.has(f)||(r.add(f),i(t,u))}function h(n){return typeof n!="function"?()=>{}:(t.add(n),()=>t.delete(n))}function c(){if(!u){u=!0;const n=()=>{if(!f){const i=e(),r=i?.sendToPropertyInspector?.subscribe;if(typeof r!="function"){setTimeout(n,0);return}f=!0;i.sendToPropertyInspector.subscribe(n=>{if(n?.event==="sendToPropertyInspector"&&n.payload)for(const i of Array.from(t))try{i(n.payload,n)}catch(r){console.error("[sc-bus] listener failed",r)}})}};n()}}const n=globalThis,o=n.SCPI=n.SCPI||{},t=new Set,r=new Set;let u=!1,f=!1;o.bus={start:c,on:h,send:i,sendOnce:s}}(),function(){function i(){const n=document.createElementNS("http://www.w3.org/2000/svg","svg");n.setAttribute("class","pi-dropdown__arrow");n.setAttribute("fill","none");n.setAttribute("height","20");n.setAttribute("width","20");n.setAttribute("xmlns","http://www.w3.org/2000/svg");const t=document.createElementNS("http://www.w3.org/2000/svg","path");return t.setAttribute("d","m5 7.5 5 5 5-5"),t.setAttribute("stroke","var(--color-primary)"),t.setAttribute("stroke-linecap","round"),t.setAttribute("stroke-linejoin","round"),t.setAttribute("stroke-width","2"),n.appendChild(t),n}function r(n,t={}){const s=!!n.querySelector(".pi-dropdown__search"),h=!!n.querySelector(".pi-dropdown__toggle"),c=!!n.querySelector(".pi-dropdown__menu");if(!s||!h||!c){const l=typeof t.placeholder=="string"?t.placeholder:n.getAttribute("data-placeholder")||"",r=document.createElement("div");r.className="pi-dropdown__input-row";const f=document.createElement("div");f.className="pi-dropdown__input-wrapper";const u=document.createElement("input");u.className="pi-dropdown__search";u.type="text";u.placeholder=l;f.appendChild(u);const e=document.createElement("div");e.className="pi-dropdown__toggle";e.appendChild(i());r.appendChild(f);r.appendChild(e);const o=document.createElement("div");o.className="pi-dropdown__menu";n.replaceChildren(r,o)}}function u(t={}){function l(){return u.classList.contains("pi-dropdown--open")}function lt(n){d&&(n?d.classList.add("pi-dropdown__arrow--open"):d.classList.remove("pi-dropdown__arrow--open"))}function it(n){if(h.textContent="",!Array.isArray(n)||n.length===0){const n=document.createElement("div");n.className="pi-dropdown__empty-state";n.textContent=bt;h.appendChild(n);return}if(ot){const t=new Map;for(const i of n){const n=String(ot(i)??"");t.has(n)||t.set(n,[]);t.get(n).push(i)}for(const[n,i]of t.entries()){if(n){const t=document.createElement("div");t.className="pi-dropdown__group-header";t.textContent=n;h.appendChild(t)}for(const n of i)h.appendChild(at(n))}}else for(const t of n)h.appendChild(at(t))}function at(n){const t=document.createElement("div");t.className="pi-dropdown__option";const u=!!n?.unbound,r=wt(n);r&&t.classList.add("disabled");const i=document.createElement("span");if(i.className="pi-dropdown__option-label",i.textContent=p(n),t.appendChild(i),u){const n=document.createElement("span");n.className="pi-dropdown__option-badge pi-dropdown__option-badge--warn";n.textContent="!";n.title="Unbound";t.appendChild(n)}return t.addEventListener("mousedown",()=>{tt=!0}),t.addEventListener("click",()=>{r||ti(n)}),t}function vt(n){const i=(n||"").toLowerCase().trim();let t=a.filter(n=>p(n).toLowerCase().includes(i));return t.length>ft&&(t=t.slice(0,ft)),t}function k(){if(!e)if(u.classList.add("pi-dropdown--open"),lt(!0),f){const n=(i.value||"").trim();it(n?vt(n):a)}else it(a)}function v(){u.classList.remove("pi-dropdown--open");lt(!1)}function yt(){e||(l()?v():k())}function gt(n){a=Array.isArray(n)?n:[];l()&&k()}function ni(n,t={}){if(nt=typeof n=="string"?n:"",ht){const n=a.find(n=>et(n)===nt);i.value=n?p(n):""}t.rerender&&l()&&k()}function ti(n){e||(nt=et(n),ht&&(i.value=p(n)),f&&(i.value=""),v(),st&&st(n),setTimeout(()=>{tt=!1},200))}function pt(n){if(f&&!e){const t=n?.target?.value??"";it(vt(t));!l()&&String(t).trim()&&k()}}function ii(){f&&(tt||(l()&&v(),i.value=""))}function ri(n){f||e||(n?.stopPropagation?.(),yt())}function ei(n,t="Loading"){const r=!!n;if(r===e){e&&o&&(o.textContent=String(t||"Loading"));return}if(w+=1,c&&(clearTimeout(c),c=null),r){e=!0;ct=Date.now();v();u.classList.remove("pi-dropdown--success");u.classList.add("pi-dropdown--loading");i.readOnly=!0;i.setAttribute("readonly","");i.blur?.();y.setAttribute("aria-disabled","true");o&&(o.textContent=String(t||"Loading"));return}const f=w,s=Date.now()-ct,h=Math.max(0,kt-s);c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--loading"),u.classList.add("pi-dropdown--success"),o&&typeof g=="string"&&g.trim().length>0&&(o.textContent=g),c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--success"),y.removeAttribute("aria-disabled"),i.readOnly=ui,fi?i.setAttribute("readonly",""):i.removeAttribute("readonly"),e=!1,c=null)},dt))},h)}const rt=t.rootId;if(!rt)return null;const u=document.getElementById(rt);if(!u)return null;r(u,{placeholder:t.placeholder});const i=u.querySelector(".pi-dropdown__search"),y=u.querySelector(".pi-dropdown__toggle"),d=u.querySelector(".pi-dropdown__arrow"),h=u.querySelector(".pi-dropdown__menu"),ut=u.querySelector(".pi-dropdown__input-wrapper");if(!i||!y||!h)return null;const f=t.searchEnabled!==!1,ft=typeof t.maxResults=="number"?t.maxResults:50,p=typeof t.getText=="function"?t.getText:n=>String(n?.text??""),et=typeof t.getValue=="function"?t.getValue:n=>String(n?.value??""),ot=typeof t.getGroup=="function"?t.getGroup:null,wt=typeof t.isDisabled=="function"?t.isDisabled:n=>!!n?.disabled,st=typeof t.onSelect=="function"?t.onSelect:null,bt=typeof t.emptyText=="string"?t.emptyText:"No items found",ht=t.displaySelectedInInput!==undefined?!!t.displaySelectedInInput:!f,kt=typeof t.minLoadingMs=="number"?t.minLoadingMs:500,dt=typeof t.successFlashMs=="number"?t.successFlashMs:220,g=typeof t.successText=="string"?t.successText:"";let a=[],nt="",tt=!1,e=!1,ct=0,w=0,c=null,s=null,o=null,b=null;if(ut){s=document.createElement("div");s.className="pi-dropdown__loading";s.setAttribute("aria-hidden","true");const n=document.createElement("span");n.className="pi-spinner";n.setAttribute("aria-hidden","true");o=document.createElement("span");o.className="pi-dropdown__loading-label";b=document.createElement("span");b.className="pi-dropdown__loading-dots";for(let n=0;n<3;n+=1){const n=document.createElement("span");n.className="pi-dropdown__loading-dot";n.textContent=".";b.appendChild(n)}s.appendChild(o);s.appendChild(b);s.appendChild(n);ut.appendChild(s)}f||(i.readOnly=!0,i.setAttribute("readonly",""));const ui=i.readOnly,fi=i.hasAttribute("readonly");if(f){const t=n.SCPI?.util?.debounce||n.debounce,r=typeof t=="function"?t(pt,150):pt;i.addEventListener("input",r);i.addEventListener("blur",ii)}else i.addEventListener("click",ri);return y.addEventListener("click",n=>{n.stopPropagation(),yt()}),document.addEventListener("click",n=>{u.contains(n.target)||(l()&&v(),f&&(i.value=""))},!0),{setItems:gt,setSelectedValue:ni,setLoading:ei}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.dropdown={initDropdown:u};n.SCDropdown=n.SCDropdown||t.ui.dropdown}(),function(){function e(n){try{return decodeURIComponent(n)}catch(t){return n}}function i(n){return typeof n!="string"?"":e(String(n).replace(/^C:\\fakepath\\/i,""))}function r(n){return typeof n!="string"||n.length===0?"":n.split("\\").pop().split("/").pop()}function o(n,t={}){const c=n.querySelector(".file-picker-container");if(!c){const h=typeof t.accept=="string"?t.accept:n.getAttribute("data-accept")||"",l=typeof t.placeholderText=="string"?t.placeholderText:n.getAttribute("data-placeholder")||"No file selected",a=typeof t.buttonText=="string"?t.buttonText:n.getAttribute("data-button-text")||"FILE",v=typeof t.selectTitle=="string"?t.selectTitle:n.getAttribute("data-select-title")||"Select file",y=typeof t.clearTitle=="string"?t.clearTitle:n.getAttribute("data-clear-title")||"Clear",i=document.createElement("div");i.className="file-picker-container";const f=document.createElement("input");f.type="file";f.style.display="none";h&&f.setAttribute("accept",h);const e=document.createElement("div");e.className="file-picker-display";const o=document.createElement("span");o.className="filename-text";o.textContent=l;e.appendChild(o);const u=document.createElement("button");u.className="file-picker-button";u.type="button";u.title=v;const s=document.createElement("span");s.className="button-icon";s.textContent=a;u.appendChild(s);const r=document.createElement("button");r.className="file-picker-clear";r.type="button";r.title=y;r.disabled=!0;r.textContent="X";i.appendChild(f);i.appendChild(e);i.appendChild(u);i.appendChild(r);n.replaceChildren(i)}}function u(t={}){function p(n){const t=typeof n=="string"&&n.length>0,i=t?rt==="full"?n:r(n):it;a.textContent=i;a.title=t?n:"";c.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(s=i,p(s),!u&&b)try{b(s)}catch(f){}if(r&&y){v=!0;try{y(i.length>0?i:null)}finally{setTimeout(()=>{v=!1},50)}}}function nt(){e.value="";f("",{persist:!0})}const w=t.rootId,u=w?document.getElementById(w):null;if(!u)return null;o(u,t);const tt=t.filenameSelector||".filename-text",it=t.placeholderText||"No file selected",h=t.settingsKey,rt=t.displayMode||"basename",b=typeof t.onValueChanged=="function"?t.onValueChanged:null,k=typeof t.initialValue=="string"?t.initialValue:"",e=u.querySelector('input[type="file"]'),d=u.querySelector(".file-picker-button"),c=u.querySelector(".file-picker-clear"),l=u.querySelector(".file-picker-display"),a=l?l.querySelector(tt):null;if(!e||!d||!c||!l||!a)return null;let s="",v=!1,y=null,g=null;return d.addEventListener("click",()=>{e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&p(t.name)}),c.addEventListener("click",()=>{nt()}),typeof h=="string"&&h.length>0&&n.SDPIComponents?.useSettings&&([g,y]=n.SDPIComponents.useSettings(h,n=>{v||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(g()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),p(""),k&&f(k,{persist:!1,silent:!0}),{setValue:f,clear:nt,getValue:()=>s}}function f(t={}){function y(n){const t=typeof n=="string"&&n.length>0,i=t?et==="full"?n:r(n):ft;l.textContent=i;l.title=t?n:"";c.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(s=i,y(s),!u&&p)try{p(s)}catch(f){}if(r&&v){a=!0;try{v(i.length>0?i:null)}finally{setTimeout(()=>{a=!1},50)}}}function g(){e.value="";f("",{persist:!0})}if(typeof t.rootId=="string"&&t.rootId.length>0)return u(t);const nt=t.inputId,tt=t.buttonId,it=t.clearId,rt=t.displayId,ut=t.filenameSelector||".filename-text",ft=t.placeholderText||"No file selected",h=t.settingsKey,et=t.displayMode||"basename",p=typeof t.onValueChanged=="function"?t.onValueChanged:null,w=typeof t.initialValue=="string"?t.initialValue:"",e=document.getElementById(nt),b=document.getElementById(tt),c=document.getElementById(it),o=document.getElementById(rt),l=o?o.querySelector(ut):null;if(!e||!b||!c||!o||!l)return null;const ot=document.createElement("div"),k=o.closest(".file-picker-container");k&&ot.appendChild(k.cloneNode(!0));let s="",a=!1,v=null,d=null;return b.addEventListener("click",()=>{e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&y(t.name)}),c.addEventListener("click",()=>{g()}),typeof h=="string"&&h.length>0&&n.SDPIComponents?.useSettings&&([d,v]=n.SDPIComponents.useSettings(h,n=>{a||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(d()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),y(""),w&&f(w,{persist:!1,silent:!0}),{setValue:f,clear:g,getValue:()=>s}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.filePicker={createFilePicker:u,initFilePicker:f};n.SCFilePicker=n.SCFilePicker||{initFilePicker:f}}(),function(){function i(n,t){if(n&&typeof n=="string"){t&&(t.href=`../css/themes/${n}`);try{localStorage.setItem(r,n)}catch(i){}}}function u(r={}){const o=r.rootId||"themeDropdown",s=r.linkId||"pi-theme-styles",u=document.getElementById(s);if(u){let e=[];n.bus?.start?.();const f=n.ui?.dropdown?.initDropdown?.({rootId:o,searchEnabled:!1,displaySelectedInInput:!0,minLoadingMs:500,successFlashMs:220,emptyText:"No themes found",getText:n=>String(n?.name??""),getValue:n=>String(n?.file??""),onSelect:r=>{const f=String(r?.file??"");f&&(i(f,u),(n.bus?.send||n.util?.sendToPlugin||t.sendToPlugin)?.("setTheme",{themeFile:f}))}});f?.setLoading?.(!0,"Loading themes");n.bus?.on?.(n=>{if(n&&(n.themesLoaded&&(e=n.themes||[],f?.setItems?.(e),f?.setLoading?.(!1)),typeof n.selectedTheme=="string"&&n.selectedTheme.length>0)){const t=n.selectedTheme;i(t,u);f?.setSelectedValue?.(t,{rerender:!0})}});try{const t=u.getAttribute("href")||"",n=t.split("/").pop().split("?")[0];n&&(i(n,u),f?.setSelectedValue?.(n,{rerender:!0}))}catch(h){}}}const t=globalThis,n=t.SCPI=t.SCPI||{},r="scsd.selectedTheme";n.theme={initThemeDropdown:u};t.SCTheme=t.SCTheme||n.theme}()
+(function(){function r(n,t){let i;return function(...r){const u=()=>{clearTimeout(i),n(...r)};clearTimeout(i);i=setTimeout(u,t)}}function u(n){document.readyState==="loading"?document.addEventListener("DOMContentLoaded",n):n()}function f(t,i={}){try{if(!n.SDPIComponents?.streamDeckClient?.send){console.warn("[sc-common] SDPIComponents.streamDeckClient.send not available");return}n.SDPIComponents.streamDeckClient.send("sendToPlugin",{event:t,...i})}catch(r){console.error("[sc-common] sendToPlugin failed",r)}}const n=globalThis,i=n.SCPI=n.SCPI||{},t=i.util=i.util||{};t.debounce=r;t.onDocumentReady=u;t.sendToPlugin=f;typeof n.debounce!="function"&&(n.debounce=r);typeof n.onDocumentReady!="function"&&(n.onDocumentReady=u);typeof n.sendToPlugin!="function"&&(n.sendToPlugin=f)})(),function(){function e(){return n.SDPIComponents?.streamDeckClient||null}function i(n,t={}){const i=e();if(!i?.send){console.warn("[sc-bus] SDPI streamDeckClient.send not available");return}i.send("sendToPlugin",{event:n,...t})}function s(n,t,u={}){const f=String(n||"").trim();if(!f){i(t,u);return}r.has(f)||(r.add(f),i(t,u))}function h(n){return typeof n!="function"?()=>{}:(t.add(n),()=>t.delete(n))}function c(){if(!u){u=!0;const n=()=>{if(!f){const i=e(),r=i?.sendToPropertyInspector?.subscribe;if(typeof r!="function"){setTimeout(n,0);return}f=!0;i.sendToPropertyInspector.subscribe(n=>{if(n?.event==="sendToPropertyInspector"&&n.payload)for(const i of Array.from(t))try{i(n.payload,n)}catch(r){console.error("[sc-bus] listener failed",r)}})}};n()}}const n=globalThis,o=n.SCPI=n.SCPI||{},t=new Set,r=new Set;let u=!1,f=!1;o.bus={start:c,on:h,send:i,sendOnce:s}}(),function(){function i(){const n=document.createElementNS("http://www.w3.org/2000/svg","svg");n.setAttribute("class","pi-dropdown__arrow");n.setAttribute("fill","none");n.setAttribute("height","20");n.setAttribute("width","20");n.setAttribute("xmlns","http://www.w3.org/2000/svg");const t=document.createElementNS("http://www.w3.org/2000/svg","path");return t.setAttribute("d","m5 7.5 5 5 5-5"),t.setAttribute("stroke","var(--color-primary)"),t.setAttribute("stroke-linecap","round"),t.setAttribute("stroke-linejoin","round"),t.setAttribute("stroke-width","2"),n.appendChild(t),n}function r(n,t={}){const s=!!n.querySelector(".pi-dropdown__search"),h=!!n.querySelector(".pi-dropdown__toggle"),c=!!n.querySelector(".pi-dropdown__menu");if(!s||!h||!c){const l=typeof t.placeholder=="string"?t.placeholder:n.getAttribute("data-placeholder")||"",r=document.createElement("div");r.className="pi-dropdown__input-row";const f=document.createElement("div");f.className="pi-dropdown__input-wrapper";const u=document.createElement("input");u.className="pi-dropdown__search";u.type="text";u.placeholder=l;f.appendChild(u);const e=document.createElement("div");e.className="pi-dropdown__toggle";e.appendChild(i());r.appendChild(f);r.appendChild(e);const o=document.createElement("div");o.className="pi-dropdown__menu";n.replaceChildren(r,o)}}function u(t={}){function l(){return u.classList.contains("pi-dropdown--open")}function lt(n){d&&(n?d.classList.add("pi-dropdown__arrow--open"):d.classList.remove("pi-dropdown__arrow--open"))}function it(n){if(h.textContent="",!Array.isArray(n)||n.length===0){const n=document.createElement("div");n.className="pi-dropdown__empty-state";n.textContent=bt;h.appendChild(n);return}if(ot){const t=new Map;for(const i of n){const n=String(ot(i)??"");t.has(n)||t.set(n,[]);t.get(n).push(i)}for(const[n,i]of t.entries()){if(n){const t=document.createElement("div");t.className="pi-dropdown__group-header";t.textContent=n;h.appendChild(t)}for(const n of i)h.appendChild(at(n))}}else for(const t of n)h.appendChild(at(t))}function at(n){const t=document.createElement("div");t.className="pi-dropdown__option";const u=!!n?.unbound,r=wt(n);r&&t.classList.add("disabled");const i=document.createElement("span");if(i.className="pi-dropdown__option-label",i.textContent=p(n),t.appendChild(i),u){const n=document.createElement("span");n.className="pi-dropdown__option-badge pi-dropdown__option-badge--warn";n.textContent="!";n.title="Unbound";t.appendChild(n)}return t.addEventListener("mousedown",()=>{tt=!0}),t.addEventListener("click",()=>{r||ti(n)}),t}function vt(n){const i=(n||"").toLowerCase().trim();let t=a.filter(n=>p(n).toLowerCase().includes(i));return t.length>ft&&(t=t.slice(0,ft)),t}function k(){if(!e)if(u.classList.add("pi-dropdown--open"),lt(!0),f){const n=(i.value||"").trim();it(n?vt(n):a)}else it(a)}function v(){u.classList.remove("pi-dropdown--open");lt(!1)}function yt(){e||(l()?v():k())}function gt(n){a=Array.isArray(n)?n:[];l()&&k()}function ni(n,t={}){if(nt=typeof n=="string"?n:"",ht){const n=a.find(n=>et(n)===nt);i.value=n?p(n):""}t.rerender&&l()&&k()}function ti(n){e||(nt=et(n),ht&&(i.value=p(n)),f&&(i.value=""),v(),st&&st(n),setTimeout(()=>{tt=!1},200))}function pt(n){if(f&&!e){const t=n?.target?.value??"";it(vt(t));!l()&&String(t).trim()&&k()}}function ii(){f&&(tt||(l()&&v(),i.value=""))}function ri(n){f||e||(n?.stopPropagation?.(),yt())}function ei(n,t="Loading"){const r=!!n;if(r===e){e&&o&&(o.textContent=String(t||"Loading"));return}if(w+=1,c&&(clearTimeout(c),c=null),r){e=!0;ct=Date.now();v();u.classList.remove("pi-dropdown--success");u.classList.add("pi-dropdown--loading");i.readOnly=!0;i.setAttribute("readonly","");i.blur?.();y.setAttribute("aria-disabled","true");o&&(o.textContent=String(t||"Loading"));return}const f=w,s=Date.now()-ct,h=Math.max(0,kt-s);c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--loading"),u.classList.add("pi-dropdown--success"),o&&typeof g=="string"&&g.trim().length>0&&(o.textContent=g),c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--success"),y.removeAttribute("aria-disabled"),i.readOnly=ui,fi?i.setAttribute("readonly",""):i.removeAttribute("readonly"),e=!1,c=null)},dt))},h)}const rt=t.rootId;if(!rt)return null;const u=document.getElementById(rt);if(!u)return null;r(u,{placeholder:t.placeholder});const i=u.querySelector(".pi-dropdown__search"),y=u.querySelector(".pi-dropdown__toggle"),d=u.querySelector(".pi-dropdown__arrow"),h=u.querySelector(".pi-dropdown__menu"),ut=u.querySelector(".pi-dropdown__input-wrapper");if(!i||!y||!h)return null;const f=t.searchEnabled!==!1,ft=typeof t.maxResults=="number"?t.maxResults:50,p=typeof t.getText=="function"?t.getText:n=>String(n?.text??""),et=typeof t.getValue=="function"?t.getValue:n=>String(n?.value??""),ot=typeof t.getGroup=="function"?t.getGroup:null,wt=typeof t.isDisabled=="function"?t.isDisabled:n=>!!n?.disabled,st=typeof t.onSelect=="function"?t.onSelect:null,bt=typeof t.emptyText=="string"?t.emptyText:"No items found",ht=t.displaySelectedInInput!==undefined?!!t.displaySelectedInInput:!f,kt=typeof t.minLoadingMs=="number"?t.minLoadingMs:500,dt=typeof t.successFlashMs=="number"?t.successFlashMs:220,g=typeof t.successText=="string"?t.successText:"";let a=[],nt="",tt=!1,e=!1,ct=0,w=0,c=null,s=null,o=null,b=null;if(ut){s=document.createElement("div");s.className="pi-dropdown__loading";s.setAttribute("aria-hidden","true");const n=document.createElement("span");n.className="pi-spinner";n.setAttribute("aria-hidden","true");o=document.createElement("span");o.className="pi-dropdown__loading-label";b=document.createElement("span");b.className="pi-dropdown__loading-dots";for(let n=0;n<3;n+=1){const n=document.createElement("span");n.className="pi-dropdown__loading-dot";n.textContent=".";b.appendChild(n)}s.appendChild(o);s.appendChild(b);s.appendChild(n);ut.appendChild(s)}f||(i.readOnly=!0,i.setAttribute("readonly",""));const ui=i.readOnly,fi=i.hasAttribute("readonly");if(f){const t=n.SCPI?.util?.debounce||n.debounce,r=typeof t=="function"?t(pt,150):pt;i.addEventListener("input",r);i.addEventListener("blur",ii)}else i.addEventListener("click",ri);return y.addEventListener("click",n=>{n.stopPropagation(),yt()}),document.addEventListener("click",n=>{u.contains(n.target)||(l()&&v(),f&&(i.value=""))},!0),{setItems:gt,setSelectedValue:ni,setLoading:ei}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.dropdown={initDropdown:u};n.SCDropdown=n.SCDropdown||t.ui.dropdown}(),function(){function e(n){try{return decodeURIComponent(n)}catch(t){return n}}function i(n){return typeof n!="string"?"":e(String(n).replace(/^C:\\fakepath\\/i,""))}function r(n){return typeof n!="string"||n.length===0?"":n.split("\\").pop().split("/").pop()}function o(n,t={}){const c=n.querySelector(".file-picker-container");if(!c){const h=typeof t.accept=="string"?t.accept:n.getAttribute("data-accept")||"",l=typeof t.placeholderText=="string"?t.placeholderText:n.getAttribute("data-placeholder")||"No file selected",a=typeof t.buttonText=="string"?t.buttonText:n.getAttribute("data-button-text")||"FILE",v=typeof t.selectTitle=="string"?t.selectTitle:n.getAttribute("data-select-title")||"Select file",y=typeof t.clearTitle=="string"?t.clearTitle:n.getAttribute("data-clear-title")||"Clear",i=document.createElement("div");i.className="file-picker-container";const f=document.createElement("input");f.type="file";f.style.display="none";h&&f.setAttribute("accept",h);const e=document.createElement("div");e.className="file-picker-display";const o=document.createElement("span");o.className="filename-text";o.textContent=l;e.appendChild(o);const u=document.createElement("button");u.className="file-picker-button";u.type="button";u.title=v;const s=document.createElement("span");s.className="button-icon";s.textContent=a;u.appendChild(s);const r=document.createElement("button");r.className="file-picker-clear";r.type="button";r.title=y;r.disabled=!0;r.textContent="X";i.appendChild(f);i.appendChild(e);i.appendChild(u);i.appendChild(r);n.replaceChildren(i)}}function u(t={}){function w(n){const t=typeof n=="string"&&n.length>0,i=t?rt==="full"?n:r(n):it;v.textContent=i;v.title=t?n:"";l.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(h=i,w(h),!u&&b)try{b(h)}catch(f){}if(r&&p){y=!0;try{p(i.length>0?i:null)}finally{setTimeout(()=>{y=!1},50)}}}function nt(){e.value="";f("",{persist:!0})}function ut(n){if(typeof n!="string"||n.length===0)return"All files|*.*";const t=[];if(n.split(",").forEach(n=>{const i=n.trim();i.startsWith(".")&&t.push("*"+i.toLowerCase())}),t.length===0)return"All files|*.*";const i=t.join(";");return"Allowed ("+i+")|"+i+"|All files|*.*"}const s=t.rootId,u=s?document.getElementById(s):null;if(!u)return null;o(u,t);const tt=t.filenameSelector||".filename-text",it=t.placeholderText||"No file selected",c=t.settingsKey,rt=t.displayMode||"basename",b=typeof t.onValueChanged=="function"?t.onValueChanged:null,k=typeof t.initialValue=="string"?t.initialValue:"",e=u.querySelector('input[type="file"]'),d=u.querySelector(".file-picker-button"),l=u.querySelector(".file-picker-clear"),a=u.querySelector(".file-picker-display"),v=a?a.querySelector(tt):null;if(!e||!d||!l||!a||!v)return null;let h="",y=!1,p=null,g=null;const ft=()=>globalThis.SCPI?.bus?.send||globalThis.SCPI?.util?.sendToPlugin||globalThis.sendToPlugin;if(globalThis.SCPI?.bus){globalThis.SCPI.bus.start?.();globalThis.SCPI.bus.on?.(n=>{if(n?.event==="filePickerResult"&&n.pickerId===s){const t=typeof n.path=="string"?n.path:"";t.length!==0&&f(t,{persist:!0})}})}return d.addEventListener("click",()=>{const t=e.getAttribute("accept")||"",i=ut(t),r=u.getAttribute("data-select-title")||"Select file",n=ft();n?n("requestFilePicker",{pickerId:s,filter:i,title:r}):e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&w(t.name)}),l.addEventListener("click",()=>{nt()}),typeof c=="string"&&c.length>0&&n.SDPIComponents?.useSettings&&([g,p]=n.SDPIComponents.useSettings(c,n=>{y||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(g()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),w(""),k&&f(k,{persist:!1,silent:!0}),{setValue:f,clear:nt,getValue:()=>h}}function f(t={}){function y(n){const t=typeof n=="string"&&n.length>0,i=t?et==="full"?n:r(n):ft;l.textContent=i;l.title=t?n:"";c.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(s=i,y(s),!u&&p)try{p(s)}catch(f){}if(r&&v){a=!0;try{v(i.length>0?i:null)}finally{setTimeout(()=>{a=!1},50)}}}function g(){e.value="";f("",{persist:!0})}if(typeof t.rootId=="string"&&t.rootId.length>0)return u(t);const nt=t.inputId,tt=t.buttonId,it=t.clearId,rt=t.displayId,ut=t.filenameSelector||".filename-text",ft=t.placeholderText||"No file selected",h=t.settingsKey,et=t.displayMode||"basename",p=typeof t.onValueChanged=="function"?t.onValueChanged:null,w=typeof t.initialValue=="string"?t.initialValue:"",e=document.getElementById(nt),b=document.getElementById(tt),c=document.getElementById(it),o=document.getElementById(rt),l=o?o.querySelector(ut):null;if(!e||!b||!c||!o||!l)return null;const ot=document.createElement("div"),k=o.closest(".file-picker-container");k&&ot.appendChild(k.cloneNode(!0));let s="",a=!1,v=null,d=null;return b.addEventListener("click",()=>{e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&y(t.name)}),c.addEventListener("click",()=>{g()}),typeof h=="string"&&h.length>0&&n.SDPIComponents?.useSettings&&([d,v]=n.SDPIComponents.useSettings(h,n=>{a||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(d()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),y(""),w&&f(w,{persist:!1,silent:!0}),{setValue:f,clear:g,getValue:()=>s}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.filePicker={createFilePicker:u,initFilePicker:f};n.SCFilePicker=n.SCFilePicker||{initFilePicker:f}}(),function(){function i(n,t){if(n&&typeof n=="string"){t&&(t.href=`../css/themes/${n}`);try{localStorage.setItem(r,n)}catch(i){}}}function u(r={}){const o=r.rootId||"themeDropdown",s=r.linkId||"pi-theme-styles",u=document.getElementById(s);if(u){let e=[];n.bus?.start?.();const f=n.ui?.dropdown?.initDropdown?.({rootId:o,searchEnabled:!1,displaySelectedInInput:!0,minLoadingMs:500,successFlashMs:220,emptyText:"No themes found",getText:n=>String(n?.name??""),getValue:n=>String(n?.file??""),onSelect:r=>{const f=String(r?.file??"");f&&(i(f,u),(n.bus?.send||n.util?.sendToPlugin||t.sendToPlugin)?.("setTheme",{themeFile:f}))}});f?.setLoading?.(!0,"Loading themes");n.bus?.on?.(n=>{if(n&&(n.themesLoaded&&(e=n.themes||[],f?.setItems?.(e),f?.setLoading?.(!1)),typeof n.selectedTheme=="string"&&n.selectedTheme.length>0)){const t=n.selectedTheme;i(t,u);f?.setSelectedValue?.(t,{rerender:!0})}});try{const t=u.getAttribute("href")||"",n=t.split("/").pop().split("?")[0];n&&(i(n,u),f?.setSelectedValue?.(n,{rerender:!0}))}catch(h){}}}const t=globalThis,n=t.SCPI=t.SCPI||{},r="scsd.selectedTheme";n.theme={initThemeDropdown:u};t.SCTheme=t.SCTheme||n.theme}()
diff --git a/PluginCore/PropertyInspector/js/src/sc-file-picker.js b/PluginCore/PropertyInspector/js/src/sc-file-picker.js
index e1915bd..c7cd06c 100644
--- a/PluginCore/PropertyInspector/js/src/sc-file-picker.js
+++ b/PluginCore/PropertyInspector/js/src/sc-file-picker.js
@@ -168,8 +168,65 @@
setValue('', {persist: true});
}
+ // Build a Win32-style filter string from an HTML `accept` attribute.
+ // ".p4k" → "Data files (*.p4k)|*.p4k|All files|*.*"
+ // ".mp3,.wav,.ogg" → "Audio (*.mp3;*.wav;*.ogg)|*.mp3;*.wav;*.ogg|All files|*.*"
+ function buildWinFilter(acceptValue) {
+ if (typeof acceptValue !== 'string' || acceptValue.length === 0) {
+ return 'All files|*.*';
+ }
+ const exts = [];
+ acceptValue.split(',').forEach(tok => {
+ const t = tok.trim();
+ if (t.startsWith('.')) {
+ exts.push('*' + t.toLowerCase());
+ }
+ });
+ if (exts.length === 0) {
+ return 'All files|*.*';
+ }
+ const list = exts.join(';');
+ return 'Allowed (' + list + ')|' + list + '|All files|*.*';
+ }
+
+ // The PI runs inside OpenDeck's Tauri WebView which doesn't grant the
+ // dialog plugin permission to plugin-served pages, AND
+ // only exposes the filename. To get a real full path on Linux/Wine we
+ // ask the plugin (running under Wine) to spawn a Win32 file dialog via
+ // GetOpenFileNameW — Wine renders it as a native GTK chooser and the
+ // returned path is Wine-resolvable.
+ const send = () =>
+ globalThis.SCPI?.bus?.send
+ || globalThis.SCPI?.util?.sendToPlugin
+ || globalThis.sendToPlugin;
+
+ // Subscribe once to filePickerResult messages from the plugin and
+ // dispatch to whichever picker owns the result (matched by rootId).
+ if (globalThis.SCPI?.bus) {
+ globalThis.SCPI.bus.start?.();
+ globalThis.SCPI.bus.on?.((payload) => {
+ if (payload?.event !== 'filePickerResult') return;
+ if (payload.pickerId !== rootId) return;
+ const path = typeof payload.path === 'string' ? payload.path : '';
+ if (path.length === 0) {
+ // Cancelled — no-op so the previous value stays visible.
+ return;
+ }
+ setValue(path, {persist: true});
+ });
+ }
+
buttonEl.addEventListener('click', () => {
- inputEl.click();
+ const accept = inputEl.getAttribute('accept') || '';
+ const filter = buildWinFilter(accept);
+ const title = rootEl.getAttribute('data-select-title') || 'Select file';
+ const s = send();
+ if (s) {
+ s('requestFilePicker', {pickerId: rootId, filter, title});
+ } else {
+ // Last-resort fallback if the bus isn't ready.
+ inputEl.click();
+ }
});
inputEl.addEventListener('change', (event) => {
diff --git a/PluginCore/manifest.json b/PluginCore/manifest.json
index b2052fe..5b41812 100644
--- a/PluginCore/manifest.json
+++ b/PluginCore/manifest.json
@@ -67,6 +67,10 @@
{
"Platform": "windows",
"MinimumVersion": "10"
+ },
+ {
+ "Platform": "linux",
+ "MinimumVersion": "5.0"
}
],
"SDKVersion": 2,
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index 48d73bc..819392c 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -1,6 +1,10 @@
net8.0-windows
+
+ true
enable
enable
false
diff --git a/docs/linux-wine.md b/docs/linux-wine.md
new file mode 100644
index 0000000..91b5d02
--- /dev/null
+++ b/docs/linux-wine.md
@@ -0,0 +1,403 @@
+# Linux (Wine) Support
+
+SCStreamDeck is a Windows .NET plugin. On Linux, it runs under
+[Wine](https://www.winehq.org/) inside a plugin host such as
+[OpenDeck](https://github.com/nekename/OpenDeck) — which natively spawns the
+`.exe` through Wine when the manifest declares the `linux` platform.
+
+Under Wine, Win32 `SendInput` produces synthetic input messages that **stay
+inside the Wine process tree** and never reach native Linux applications
+such as Star Citizen launched via Proton/Lutris/Heroic. The plugin works
+around this by detecting Wine at runtime and routing input through
+[`ydotool`](https://github.com/ReimuNotMoe/ydotool) — a Linux utility that
+synthesises events through `/dev/uinput`, so they are seen as real keyboard
+and mouse activity by every application in the session, including games.
+
+When running on native Windows nothing changes: the patch is a *passive*
+branch that only activates if it detects Wine.
+
+---
+
+## How it works
+
+```
+OpenDeck (native Linux Tauri app)
+ │
+ │ spawn (Wine, declared in manifest "OS": [{"Platform":"linux"}])
+ ▼
+SCStreamDeck.exe ── BarRaider SD Tools ── WebSocket ── back to OpenDeck
+ │
+ │ keyDown
+ ▼
+ActionKeys/AdaptiveKey.cs · ToggleKey.cs
+ │
+ ▼
+PluginCore/Services/Keybinding/ActivationHandlers/KeybindingInputExecutor.cs
+ │
+ ▼
+WindowsInput.InputSimulator
+ → KeyboardSimulator / MouseSimulator → InputBuilder → INPUT[]
+ │
+ ▼
+IInputMessageDispatcher.DispatchInput(INPUT[])
+ │
+ ├── Windows (native) → NativeMethods.SendInput → kernel
+ │
+ └── Wine → LinuxInputBridge
+ │
+ ▼
+ Z:\usr\bin\ydotool key …
+ │
+ ▼ Unix socket
+ ydotoold daemon (native Linux)
+ │
+ ▼ /dev/uinput
+ kernel → focused window (e.g. Star Citizen)
+```
+
+The branch is taken inside
+[`WindowsInputMessageDispatcher.DispatchInput`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs):
+the existing dispatcher first checks `WineDetector.IsWine` and forwards the
+`INPUT[]` array to `LinuxInputBridge.DispatchInput` instead of calling
+`SendInput` when it is `true`.
+
+### `WineDetector`
+
+Single-shot probe that resolves `ntdll!wine_get_version` via `LoadLibrary`
++ `GetProcAddress`. The symbol exists only inside Wine, so the boolean is
+true under Wine and false everywhere else. Result is cached in a
+`Lazy` for the lifetime of the process.
+
+### `LinuxInputBridge`
+
+Walks the `INPUT[]` array once per `DispatchInput` call:
+
+| INPUT type | Translation |
+|---|---|
+| `INPUT_KEYBOARD` with VK | `Vk2Evdev.TryGet(vk)` → evdev keycode |
+| `INPUT_KEYBOARD` with `KEYEVENTF_SCANCODE` | `Vk2Evdev.TryFromScan(scan, extended)` → evdev (PS/2 Set 1 identity for non-extended; small table for extended) |
+| `INPUT_KEYBOARD` with `KEYEVENTF_UNICODE` | `ydotool type ""` |
+| `INPUT_MOUSE` button down/up | `ydotool click 0x` |
+| `INPUT_MOUSE` wheel | `ydotool mousemove --wheel -- dx dy` |
+
+All keyboard events from a single `DispatchInput` invocation are batched
+into one `ydotool key …` call so a 4-key combo only costs one
+`fork+exec` (~5–20 ms).
+
+The bridge resolves the daemon socket at startup by reading
+`Z:\proc\self\environ` (the Linux process environment is reachable from
+Wine via the `Z:` mapped drive). It honours `$XDG_RUNTIME_DIR/.ydotool_socket`
+where modern systemd-user setups put the socket, and falls back to
+`/tmp/.ydotool_socket` on older configurations. The `ydotool` binary path
+defaults to `/usr/bin/ydotool` and may be overridden via the
+`YDOTOOL_PATH` environment variable.
+
+Unknown virtual-keys and unknown scan codes are reported once per process
+on `Console.Error`. They show up in the plugin's log under
+`~/.local/share/opendeck/logs/plugins/com.jarex985.scstreamdeck.sdPlugin.log`
+prefixed with `[LinuxInputBridge]`. File a `Vk2Evdev` addition if you see
+real keystrokes mapped to game actions appearing there.
+
+### `Vk2Evdev`
+
+Translation table between Win32 virtual-key codes (and PS/2 Set 1 / DIK
+scan codes) and Linux evdev keycodes. The full keyboard is covered:
+alpha-num, function row, modifiers (with L/R distinction), numpad,
+navigation cluster, OEM punctuation (US layout for VK; PS/2 scan path
+covers all layouts).
+
+---
+
+## Prerequisites
+
+All of the following must be in place before SCStreamDeck can deliver
+input on Linux.
+
+### 1. OpenDeck
+
+```bash
+# Arch / CachyOS
+yay -S opendeck-bin
+
+# Or via Flathub (note: the Flatpak build cannot spawn the .exe under Wine
+# for license/sandboxing reasons — install the native package instead).
+```
+
+### 2. Wine
+
+```bash
+sudo pacman -S wine
+```
+
+Wine 9.0 or newer is recommended for the .NET 8 desktop runtime that
+SCStreamDeck targets.
+
+Inside the Wine prefix that OpenDeck uses to spawn the plugin, install
+the .NET 8 runtime via [winetricks](https://github.com/Winetricks/winetricks):
+
+```bash
+winetricks -q dotnet8
+winetricks -q dotnetdesktop8
+```
+
+The first verb installs the .NET 8 base runtime; the second adds the
+Windows Desktop runtime (WinForms/WPF surface) which the plugin
+references transitively. Both are non-interactive (`-q`).
+
+### 3. .NET 8 SDK (only if building from source)
+
+```bash
+sudo pacman -S dotnet-sdk-8.0
+```
+
+A pre-built artefact ships with each release on GitHub; this is only
+required if you build the plugin yourself.
+
+### 4. GStreamer plugins (for audio click sounds)
+
+Click sounds attached to Adaptive Key / Toggle Key buttons are played
+by NAudio's `AudioFileReader`, which under Wine ultimately delegates
+to Media Foundation. Wine's `winegstreamer` then routes the actual
+decode through the host's GStreamer plugins. To cover the most common
+audio formats (WAV, MP3, M4A, OGG, FLAC) install the full set of
+GStreamer plugins:
+
+```bash
+sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad \
+ gst-plugins-ugly gst-libav
+```
+
+WAV-only setups can skip this. MP3 specifically needs `gst-plugins-good`
+(which provides `id3demux` — without it Wine logs
+`Missing decoder: ID3 tag (application/x-id3)` and the sound never
+plays).
+
+### 5. `ydotool` + `ydotoold`
+
+```bash
+sudo pacman -S ydotool
+systemctl --user enable --now ydotool
+```
+
+Confirm the socket exists at the expected location:
+
+```bash
+ls -la "$XDG_RUNTIME_DIR/.ydotool_socket"
+# srw------- 1 you you 0 ... /run/user/1000/.ydotool_socket
+```
+
+If you see `/tmp/.ydotool_socket` instead, that also works — the bridge
+auto-detects.
+
+### 6. `/dev/uinput` permission
+
+`ydotoold` runs unprivileged and needs write access to `/dev/uinput`. By
+default that device is owned `root:root mode 0600`. Three ways to grant
+access, in order of preference:
+
+**A. You already have it.** Some packages (OpenLinkHub, Razer driver,
+Steam) ship a permissive udev rule that adds ACLs for your user. Check:
+
+```bash
+getfacl /dev/uinput
+# user::rw-
+# user:you:rw- ← if this line is present, you're done
+```
+
+**B. Group-based rule.** If no ACL is present, install a udev rule that
+gives the `input` group access:
+
+```bash
+echo 'KERNEL=="uinput", GROUP="input", MODE="0660", OPTIONS+="static_node=uinput"' \
+ | sudo tee /etc/udev/rules.d/99-uinput.rules
+sudo udevadm control --reload-rules
+sudo udevadm trigger /dev/uinput
+sudo usermod -aG input "$USER"
+# Log out and back in for the new group to take effect.
+```
+
+**C. Loosen permissions (last resort).** Less secure — any process can
+synthesise input:
+
+```bash
+sudo chmod 666 /dev/uinput
+```
+
+---
+
+## Building from source
+
+```bash
+git clone https://github.com/Alexconquer/SCStreamDeck.git
+cd SCStreamDeck
+# Linux cross-compile to win-x64: the SDK pulls the Windows Desktop
+# reference pack from NuGet (enabled via ).
+dotnet publish PluginCore/PluginCore.csproj \
+ -c Release -r win-x64 --self-contained \
+ -p:PublishSingleFile=false \
+ -o "$HOME/.config/opendeck/plugins/com.jarex985.scstreamdeck.sdPlugin"
+```
+
+Then restart OpenDeck (close fully via the tray icon, reopen). On
+startup you should see in
+`~/.local/share/opendeck/logs/opendeck.log`:
+
+```
+Registered plugin com.jarex985.scstreamdeck.sdPlugin
+```
+
+and in the per-plugin log:
+
+```
+[WindowsInput] WineDetector.IsWine = True
+```
+
+> **Important:** do not deploy the plugin via a symlink that resolves to a
+> directory called `publish/` (e.g. the default `bin/Release/.../win-x64/publish/`).
+> OpenDeck registers the plugin under the resolved directory's basename,
+> so it would appear as `publish` instead of
+> `com.jarex985.scstreamdeck.sdPlugin`, breaking the property-inspector
+> paths stored inside existing button configurations. Publish directly
+> with `-o ` as shown above, or copy the publish output into
+> the correctly-named folder.
+
+---
+
+## Verifying the setup
+
+After OpenDeck restart, open any text editor (gedit, kate, vim in a
+terminal), give it focus, and press a Stream Deck button whose
+`function` references a keyboard-bound SC action — the keystroke must
+appear in the editor.
+
+If nothing happens, in order:
+
+1. **Plugin loaded?** Check `~/.local/share/opendeck/logs/opendeck.log`
+ for `Registered plugin com.jarex985.scstreamdeck.sdPlugin`. If absent,
+ the manifest's `OS` array might be missing `linux`, or the plugin
+ directory has the wrong name.
+2. **Wine detected?** Grep
+ `~/.local/share/opendeck/logs/plugins/com.jarex985.scstreamdeck.sdPlugin.log`
+ for `WineDetector.IsWine = True`. If it says `False`, your Wine build
+ is hiding the `wine_get_version` symbol (uncommon).
+3. **ydotool daemon up?** `systemctl --user status ydotool` and
+ `ls $XDG_RUNTIME_DIR/.ydotool_socket`.
+4. **uinput accessible?** `getfacl /dev/uinput` and look for an entry
+ for your user, or check group membership.
+5. **Unknown keystrokes?** Look for `[LinuxInputBridge] unknown VK` or
+ `unknown SCAN` lines in the plugin log. File an issue with the line
+ contents and the SC action you triggered, so the relevant entry can
+ be added to `Vk2Evdev`.
+
+---
+
+## Limitations
+
+- **Latency**: each `DispatchInput` call spawns `ydotool` once (regardless
+ of combo size). Cold spawn is typically 5–20 ms on a modern desktop.
+ For sustained rapid input this is noticeable; for SC action buttons
+ it is imperceptible. A future improvement would be to talk to the
+ ydotool socket directly from Wine via WinSock.
+- **Joystick / gamepad / VR bindings**: these are not synthesised by
+ `ydotool` and are out of scope. SCStreamDeck's existing behaviour
+ (game bindings configured in-game's actionmaps) remains intact for
+ keyboard and mouse bindings only.
+- **Mouse buttons** beyond left/right/middle and side-button 1/2 are
+ not currently mapped — patches welcome.
+- **macOS** is not handled. The Wine detector returns `false` there and
+ the code falls through to `SendInput`, which won't work on macOS. If
+ someone wants to add a `LinuxInputBridge` equivalent for macOS
+ (e.g. via `CGEventPost`), the architecture supports it cleanly: add a
+ new dispatcher and a corresponding `OsDetector` check.
+
+---
+
+## File picker paths (audio, Data.p4k)
+
+OpenDeck's Property Inspector runs inside a Tauri 2 WebView that
+**does not grant the `dialog:*` capability to plugin-served pages**,
+and the WebView's HTML `` only exposes the
+filename to JavaScript — not the full path. Both restrictions
+prevented the picker from returning a usable absolute path under
+Wine.
+
+The plugin works around this by opening the file dialog from the
+Wine side itself. Clicking the "FILE" button no longer triggers a
+hidden ``; it sends a `requestFilePicker` event to the
+plugin over the SCPI bus. The plugin spawns an STA thread, calls
+`CoCreateInstance(CLSID_FileOpenDialog)` and shows the dialog via
+the modern Common Item Dialog COM API
+(`IFileOpenDialog` — recommended by Microsoft since Vista, and
+implemented by Wine's `comdlg32` since the same era). When the
+user confirms, the plugin sends a `filePickerResult` event back to
+the PI with the absolute path (typically `Z:\home\…` or
+`C:\users\…`), which the PI persists via `setSettings` or
+`sendToPlugin`-back-to-its-handler depending on the action.
+
+Round-trip recap:
+
+```
+PI buttonEl.click
+ → SCPI.bus.send('requestFilePicker', {pickerId, filter, title})
+ → WindowsInput.SDConnection.OnSendToPlugin
+ → ControlPanelKey.HandleRequestFilePicker / SCActionBase.HandleRequestFilePicker
+ → WineFileDialog.ShowOpenFileDialogAsync (STA thread)
+ → CLSID_FileOpenDialog → IFileOpenDialog.Show()
+ ← IShellItem.GetDisplayName(SIGDN_FILESYSPATH) → full Wine path
+ ← Connection.SendToPropertyInspectorAsync(filePickerResult)
+ ← SCPI.bus listener → picker.setValue(path)
+```
+
+On Windows native the same flow runs and the user sees the
+standard shell file picker. Under Wine on Linux the same COM
+class is rendered by Wine's own dialog backend, so users see a
+native-looking file chooser without any GTK shim.
+
+## Internals for contributors
+
+The Wine-aware path is contained in five files; everything else in the
+codebase is left as upstream.
+
+| File | Purpose |
+|---|---|
+| [`InputSimulatorPlus/WindowsInput/WineDetector.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/WineDetector.cs) | One-shot Wine probe via `ntdll!wine_get_version`, cached in `Lazy` |
+| [`InputSimulatorPlus/WindowsInput/LinuxInputBridge.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/LinuxInputBridge.cs) | `INPUT[]` → `ydotool` command translation |
+| [`InputSimulatorPlus/WindowsInput/Native/Vk2Evdev.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/Native/Vk2Evdev.cs) | VK_* + PS/2 Set 1 scan code → Linux evdev keycode tables |
+| [`InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs) | 3-line branch that delegates to `LinuxInputBridge` when `WineDetector.IsWine` |
+| [`PluginCore/Common/WineFileDialog.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/PluginCore/Common/WineFileDialog.cs) | COM wrapper around `IFileOpenDialog` (`CLSID_FileOpenDialog`) on an STA thread. Backs the new `requestFilePicker` PI ↔ plugin event so the Property Inspector receives real absolute paths |
+
+Plus three small touches at the integration boundaries:
+
+- `PluginCore/ActionKeys/ControlPanelKey.cs` and
+ `PluginCore/ActionKeys/SCActionBase.cs` each route the new
+ `requestFilePicker` PI event to `WineFileDialog.ShowOpenFileDialogAsync`
+ and post the result back via
+ `Connection.SendToPropertyInspectorAsync({event:"filePickerResult", …})`.
+- `PluginCore/PropertyInspector/js/src/sc-file-picker.js` swaps the
+ hidden-input click for a `SCPI.bus.send('requestFilePicker', …)` and
+ installs a one-time listener that calls `setValue(path)` when the
+ matching `filePickerResult` arrives.
+- `PluginCore/manifest.json` appends
+ `{"Platform":"linux","MinimumVersion":"5.0"}` to the `OS` array so
+ OpenDeck spawns the plugin via Wine on Linux hosts.
+
+Three ancillary build adjustments make the project cross-build cleanly
+from Linux to win-x64 (each is a no-op on Windows hosts):
+
+- `PluginCore/PluginCore.csproj` adds
+ `true` so the
+ Windows Desktop reference pack is pulled from NuGet when building on
+ Linux; the inline `` command for `PiAssetsBuilder` also has
+ its hard-coded `\` separators replaced with `/` (tolerated by
+ Windows MSBuild as well).
+- `Tests/Tests.csproj` carries the same `EnableWindowsTargeting`
+ setting.
+- `PluginCore/Common/DirectInputDisplayMapper.cs` substitutes the
+ `['+']` collection-expression argument to `string.Split` with a
+ `static readonly char[]` field, sidestepping a CS0121 ambiguity
+ that triggers on some .NET 8 SDK builds and also satisfying
+ CA1861.
+
+`InputSimulatorPlus/WindowsInput/WindowsInput.csproj` is unchanged
+except for three new `` entries pointing at the
+files added above — its legacy MSBuild format is preserved.
diff --git a/mkdocs.yml b/mkdocs.yml
index a277d23..2fd46d1 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -44,6 +44,8 @@ nav:
- Themes:
- About Themes: themes.md
- Theme Gallery: themegallery.md
+ - Platforms:
+ - Linux (Wine): linux-wine.md
- Help:
- Troubleshooting: troubleshooting.md
- FAQ: faq.md