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