From af4a6623aa564afb1e9d3b3d58690da99c17da41 Mon Sep 17 00:00:00 2001 From: Alexconquer Date: Fri, 15 May 2026 19:04:26 +0200 Subject: [PATCH 1/2] Add Wine runtime detection + ydotool input dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect Wine at startup via ntdll!wine_get_version and route IInputMessageDispatcher through ydotool (uinput) when detected, so synthetic keystrokes reach native Linux apps (e.g. Star Citizen under Proton) instead of staying inside the Wine prefix. Bridge the Property Inspector picker through Win32 GetOpenFileNameW (rendered by Wine as a native GTK dialog) so PI settings receive real file paths. Zero behaviour change on Windows native — WineDetector returns false and the existing SendInput path runs unchanged. See docs/linux-wine.md for architecture, prerequisites, and build notes. --- .../WindowsInput/LinuxInputBridge.cs | 277 +++++++++++++++ .../WindowsInput/Native/Vk2Evdev.cs | 217 ++++++++++++ .../WindowsInput/WindowsInput.csproj | 3 + .../WindowsInputMessageDispatcher.cs | 10 + .../WindowsInput/WineDetector.cs | 36 ++ PluginCore/ActionKeys/ControlPanelKey.cs | 24 +- PluginCore/ActionKeys/SCActionBase.cs | 29 ++ PluginCore/Common/DirectInputDisplayMapper.cs | 4 +- PluginCore/Common/WineFileDialog.cs | 169 +++++++++ PluginCore/PluginCore.csproj | 5 +- .../PropertyInspector/js/sc-components.js | 2 +- .../js/src/sc-file-picker.js | 59 +++- PluginCore/manifest.json | 4 + Tests/Tests.csproj | 4 + docs/linux-wine.md | 331 ++++++++++++++++++ mkdocs.yml | 2 + 16 files changed, 1170 insertions(+), 6 deletions(-) create mode 100644 InputSimulatorPlus/WindowsInput/LinuxInputBridge.cs create mode 100644 InputSimulatorPlus/WindowsInput/Native/Vk2Evdev.cs create mode 100644 InputSimulatorPlus/WindowsInput/WineDetector.cs create mode 100644 PluginCore/Common/WineFileDialog.cs create mode 100644 docs/linux-wine.md 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..d9d15f3 --- /dev/null +++ b/PluginCore/Common/WineFileDialog.cs @@ -0,0 +1,169 @@ +using System.Runtime.InteropServices; + +namespace SCStreamDeck.Common; + +/// +/// Wraps the Win32 GetOpenFileNameW system dialog 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!GetOpenFileNameW renders as a +/// native GTK file chooser, so the user sees a familiar Linux dialog +/// and the returned path is Wine-resolvable (typically Z:\… +/// for Unix files). +/// +/// The dialog is modal and requires an STA thread, so callers should +/// invoke which spawns a +/// dedicated STA worker. +/// +internal static class WineFileDialog +{ + private const int OfnFileMustExist = 0x1000; + private const int OfnPathMustExist = 0x0800; + private const int OfnNoChangeDir = 0x0008; + + private const int MaxPath = 4096; + + // Note on string fields: LPCWSTR fields that may contain embedded + // NUL bytes (`lpstrFilter`, `lpstrCustomFilter`) MUST be passed as + // raw pointers — managed-string marshaling truncates at the first + // NUL. Filter format is e.g. "Name1\0Pattern1\0Name2\0Pattern2\0\0". + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct OPENFILENAMEW + { + public int lStructSize; + public nint hwndOwner; + public nint hInstance; + public nint lpstrFilter; // raw pointer (embedded NULs) + public nint lpstrCustomFilter; // raw pointer (embedded NULs) or 0 + public int nMaxCustFilter; + public int nFilterIndex; + public nint lpstrFile; + public int nMaxFile; + public nint lpstrFileTitle; + public int nMaxFileTitle; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpstrInitialDir; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpstrTitle; + public int Flags; + public ushort nFileOffset; + public ushort nFileExtension; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpstrDefExt; + public nint lCustData; + public nint lpfnHook; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpTemplateName; + public nint pvReserved; + public int dwReserved; + public int FlagsEx; + } + + [DllImport("comdlg32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern bool GetOpenFileNameW(ref OPENFILENAMEW lpofn); + + /// + /// Show a Win32 open-file dialog and return the picked path. + /// Returns null on cancel. + /// + /// Window title. + /// + /// Filter pattern using the standard Windows format with | + /// as the separator, e.g. "Data.p4k|*.p4k|All files|*.*". + /// The method converts to the native NUL-delimited form. + /// + 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); + } + }); + // GetOpenFileNameW uses COM internally — STA is required for the + // shell file dialog to function correctly. + t.SetApartmentState(ApartmentState.STA); + t.IsBackground = true; + t.Name = "WineFileDialog"; + t.Start(); + return tcs.Task; + } + + private static string? ShowOpenFileDialog(string title, string filter) + { + nint fileBuffer = Marshal.AllocHGlobal(MaxPath * sizeof(char)); + nint filterBuffer = AllocFilterBuffer(filter); + try + { + // Zero-fill the file buffer — GetOpenFileName uses it as in/out. + for (int i = 0; i < MaxPath; i++) + { + Marshal.WriteInt16(fileBuffer, i * sizeof(char), 0); + } + + OPENFILENAMEW ofn = new() + { + lStructSize = Marshal.SizeOf(), + lpstrTitle = title, + lpstrFilter = filterBuffer, + lpstrFile = fileBuffer, + nMaxFile = MaxPath, + Flags = OfnFileMustExist | OfnPathMustExist | OfnNoChangeDir, + }; + + if (!GetOpenFileNameW(ref ofn)) + { + return null; + } + + return Marshal.PtrToStringUni(fileBuffer); + } + finally + { + Marshal.FreeHGlobal(fileBuffer); + if (filterBuffer != nint.Zero) + { + Marshal.FreeHGlobal(filterBuffer); + } + } + } + + // Filter input: pipe-separated pairs, e.g. "Data.p4k|*.p4k|All files|*.*". + // Allocates a UTF-16 buffer with each segment followed by NUL and a final + // double-NUL terminator — the format required by GetOpenFileName. + private static nint AllocFilterBuffer(string filter) + { + string[] parts = string.IsNullOrEmpty(filter) + ? ["All files", "*.*"] + : filter.Split('|'); + + // Total UTF-16 char count = sum(parts.length + 1 NUL each) + 1 final NUL. + int totalChars = 1; // final terminating NUL + foreach (string p in parts) + { + totalChars += p.Length + 1; + } + + nint buf = Marshal.AllocHGlobal(totalChars * sizeof(char)); + int offset = 0; + foreach (string p in parts) + { + foreach (char c in p) + { + Marshal.WriteInt16(buf, offset, c); + offset += sizeof(char); + } + Marshal.WriteInt16(buf, offset, 0); // NUL after each part + offset += sizeof(char); + } + Marshal.WriteInt16(buf, offset, 0); // final double-NUL + return buf; + } +} 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..d13be6c --- /dev/null +++ b/docs/linux-wine.md @@ -0,0 +1,331 @@ +# 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. Wine-Mono is bundled with current Wine builds and +provides the desktop runtime out of the box. + +### 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. `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. + +### 5. `/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 in the host's WebView, not under Wine. +When you click the "FILE" button to pick an audio click sound (Adaptive +Key / Toggle Key) or a `Data.p4k` install override (Control Panel), the +dialog that appears is the host's **native Linux** file picker. It hands +back paths like `/home/user/Music/foo.mp3` rather than the `C:\…` or +`Z:\…` form Wine would normally expose. + +The plugin compensates server-side: `WinePath.Translate` rewrites any +incoming Unix-style absolute path to `Z:\…` when running under Wine, +before the path is passed to `File.Exists`, `NAudio.AudioFileReader`, +or the P4K opener. Existing Windows-style paths (e.g. saved from a +prior Windows install of SCStreamDeck) are left untouched. + +The UI still displays the Linux-style path it captured — only the +behind-the-scenes I/O is rewritten. A future improvement would be a +custom Wine-aware file dialog rendered by the plugin itself; not in +scope here. + +## Internals for contributors + +The Wine-aware path is contained in five files; everything else in the +codebase is untouched. + +| File | Purpose | +|---|---| +| [`InputSimulatorPlus/WindowsInput/WineDetector.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/WineDetector.cs) | One-shot Wine probe via `wine_get_version` | +| [`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 scan code → evdev keycode tables | +| [`InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs) | 4-line patch that delegates to `LinuxInputBridge` when `WineDetector.IsWine` | +| [`PluginCore/Infrastructure/WinePath.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/PluginCore/Infrastructure/WinePath.cs) | Rewrites Linux paths from the native file picker to `Z:\` form for NAudio / `File.*` consumers | + +Two ancillary build adjustments make the project cross-build cleanly +from Linux to win-x64: + +- `InputSimulatorPlus/WindowsInput/WindowsInput.csproj` was converted + from the legacy MSBuild format to the SDK-style format targeting + `net8.0-windows`. The legacy project required a .NET Framework 4.8 + targeting pack that isn't shipped on Linux. +- `PluginCore/PluginCore.csproj` adds `true` + so the Windows Desktop reference pack is pulled from NuGet when + building on Linux. The setting is a no-op on Windows. + +Manifest change: `"linux"` is appended to the `OS` array in +`PluginCore/manifest.json` so OpenDeck spawns the plugin via Wine on +Linux hosts. 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 From 620ae3d4601b09cb8bba1e05322c282d92b036fc Mon Sep 17 00:00:00 2001 From: Alexconquer Date: Sat, 16 May 2026 10:15:04 +0200 Subject: [PATCH 2/2] Use IFileOpenDialog COM API for the file picker Replace the legacy comdlg32!GetOpenFileNameW P/Invoke with the modern Common Item Dialog (CLSID_FileOpenDialog / IFileOpenDialog, introduced in Vista). Public API of WineFileDialog is unchanged so callers don't move. Windows native users now get the shell-style file picker instead of the XP-era dialog; Wine renders the same COM dialog via its dlls/comdlg32/itemdlg.c implementation. docs/linux-wine.md is updated to match (describes the COM flow, drops the "future migration" note) and gains two new prerequisite sections: - winetricks dotnet8 / dotnetdesktop8 inside the Wine prefix that spawns the plugin. - GStreamer plugins on the Linux host. gst-plugins-good in particular is required for MP3 click sounds (provides id3demux); without it winegstreamer logs "Missing decoder: application/x-id3" and the sound never plays. --- PluginCore/Common/WineFileDialog.cs | 259 +++++++++++++++++----------- docs/linux-wine.md | 152 +++++++++++----- 2 files changed, 268 insertions(+), 143 deletions(-) diff --git a/PluginCore/Common/WineFileDialog.cs b/PluginCore/Common/WineFileDialog.cs index d9d15f3..10473f7 100644 --- a/PluginCore/Common/WineFileDialog.cs +++ b/PluginCore/Common/WineFileDialog.cs @@ -3,76 +3,120 @@ namespace SCStreamDeck.Common; /// -/// Wraps the Win32 GetOpenFileNameW system dialog 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. +/// 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!GetOpenFileNameW renders as a -/// native GTK file chooser, so the user sees a familiar Linux dialog -/// and the returned path is Wine-resolvable (typically Z:\… -/// for Unix files). +/// 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). /// -/// The dialog is modal and requires an STA thread, so callers should -/// invoke which spawns a +/// 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 { - private const int OfnFileMustExist = 0x1000; - private const int OfnPathMustExist = 0x0800; - private const int OfnNoChangeDir = 0x0008; + // 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"); - private const int MaxPath = 4096; + [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; - // Note on string fields: LPCWSTR fields that may contain embedded - // NUL bytes (`lpstrFilter`, `lpstrCustomFilter`) MUST be passed as - // raw pointers — managed-string marshaling truncates at the first - // NUL. Filter format is e.g. "Name1\0Pattern1\0Name2\0Pattern2\0\0". [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct OPENFILENAMEW + 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 { - public int lStructSize; - public nint hwndOwner; - public nint hInstance; - public nint lpstrFilter; // raw pointer (embedded NULs) - public nint lpstrCustomFilter; // raw pointer (embedded NULs) or 0 - public int nMaxCustFilter; - public int nFilterIndex; - public nint lpstrFile; - public int nMaxFile; - public nint lpstrFileTitle; - public int nMaxFileTitle; - [MarshalAs(UnmanagedType.LPWStr)] public string? lpstrInitialDir; - [MarshalAs(UnmanagedType.LPWStr)] public string? lpstrTitle; - public int Flags; - public ushort nFileOffset; - public ushort nFileExtension; - [MarshalAs(UnmanagedType.LPWStr)] public string? lpstrDefExt; - public nint lCustData; - public nint lpfnHook; - [MarshalAs(UnmanagedType.LPWStr)] public string? lpTemplateName; - public nint pvReserved; - public int dwReserved; - public int FlagsEx; + // 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); } - [DllImport("comdlg32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - private static extern bool GetOpenFileNameW(ref OPENFILENAMEW lpofn); - - /// - /// Show a Win32 open-file dialog and return the picked path. - /// Returns null on cancel. - /// - /// Window title. - /// - /// Filter pattern using the standard Windows format with | - /// as the separator, e.g. "Data.p4k|*.p4k|All files|*.*". - /// The method converts to the native NUL-delimited form. - /// public static Task ShowOpenFileDialogAsync(string title, string filter) { TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -87,8 +131,7 @@ private struct OPENFILENAMEW tcs.SetException(ex); } }); - // GetOpenFileNameW uses COM internally — STA is required for the - // shell file dialog to function correctly. + // IFileOpenDialog uses COM internally and requires STA. t.SetApartmentState(ApartmentState.STA); t.IsBackground = true; t.Name = "WineFileDialog"; @@ -98,72 +141,82 @@ private struct OPENFILENAMEW private static string? ShowOpenFileDialog(string title, string filter) { - nint fileBuffer = Marshal.AllocHGlobal(MaxPath * sizeof(char)); - nint filterBuffer = AllocFilterBuffer(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 { - // Zero-fill the file buffer — GetOpenFileName uses it as in/out. - for (int i = 0; i < MaxPath; i++) + if (!string.IsNullOrEmpty(title)) { - Marshal.WriteInt16(fileBuffer, i * sizeof(char), 0); + dialog.SetTitle(title); } - OPENFILENAMEW ofn = new() + COMDLG_FILTERSPEC[] specs = ParseFilter(filter); + if (specs.Length > 0) { - lStructSize = Marshal.SizeOf(), - lpstrTitle = title, - lpstrFilter = filterBuffer, - lpstrFile = fileBuffer, - nMaxFile = MaxPath, - Flags = OfnFileMustExist | OfnPathMustExist | OfnNoChangeDir, - }; - - if (!GetOpenFileNameW(ref ofn)) + 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; } - return Marshal.PtrToStringUni(fileBuffer); + dialog.GetResult(out IShellItem item); + try + { + item.GetDisplayName(SIGDN_FILESYSPATH, out string path); + return path; + } + finally + { + Marshal.ReleaseComObject(item); + } } finally { - Marshal.FreeHGlobal(fileBuffer); - if (filterBuffer != nint.Zero) - { - Marshal.FreeHGlobal(filterBuffer); - } + Marshal.ReleaseComObject(dialog); } } // Filter input: pipe-separated pairs, e.g. "Data.p4k|*.p4k|All files|*.*". - // Allocates a UTF-16 buffer with each segment followed by NUL and a final - // double-NUL terminator — the format required by GetOpenFileName. - private static nint AllocFilterBuffer(string filter) + // IFileDialog wants COMDLG_FILTERSPEC[]{ {pszName, pszSpec}, ... } with + // multiple patterns inside a single pszSpec separated by ';'. + private static COMDLG_FILTERSPEC[] ParseFilter(string filter) { - string[] parts = string.IsNullOrEmpty(filter) - ? ["All files", "*.*"] - : filter.Split('|'); + if (string.IsNullOrEmpty(filter)) + { + return [new COMDLG_FILTERSPEC { pszName = "All files", pszSpec = "*.*" }]; + } - // Total UTF-16 char count = sum(parts.length + 1 NUL each) + 1 final NUL. - int totalChars = 1; // final terminating NUL - foreach (string p in parts) + string[] parts = filter.Split('|'); + int pairs = parts.Length / 2; + if (pairs == 0) { - totalChars += p.Length + 1; + return [new COMDLG_FILTERSPEC { pszName = "All files", pszSpec = "*.*" }]; } - nint buf = Marshal.AllocHGlobal(totalChars * sizeof(char)); - int offset = 0; - foreach (string p in parts) + var result = new COMDLG_FILTERSPEC[pairs]; + for (int i = 0; i < pairs; i++) { - foreach (char c in p) - { - Marshal.WriteInt16(buf, offset, c); - offset += sizeof(char); - } - Marshal.WriteInt16(buf, offset, 0); // NUL after each part - offset += sizeof(char); + result[i].pszName = parts[i * 2]; + result[i].pszSpec = parts[i * 2 + 1]; } - Marshal.WriteInt16(buf, offset, 0); // final double-NUL - return buf; + return result; } } diff --git a/docs/linux-wine.md b/docs/linux-wine.md index d13be6c..91b5d02 100644 --- a/docs/linux-wine.md +++ b/docs/linux-wine.md @@ -130,8 +130,19 @@ sudo pacman -S wine ``` Wine 9.0 or newer is recommended for the .NET 8 desktop runtime that -SCStreamDeck targets. Wine-Mono is bundled with current Wine builds and -provides the desktop runtime out of the box. +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) @@ -142,7 +153,26 @@ 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. `ydotool` + `ydotoold` +### 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 @@ -159,7 +189,7 @@ ls -la "$XDG_RUNTIME_DIR/.ydotool_socket" If you see `/tmp/.ydotool_socket` instead, that also works — the bridge auto-detects. -### 5. `/dev/uinput` permission +### 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 @@ -284,48 +314,90 @@ If nothing happens, in order: ## File picker paths (audio, Data.p4k) -OpenDeck's Property Inspector runs in the host's WebView, not under Wine. -When you click the "FILE" button to pick an audio click sound (Adaptive -Key / Toggle Key) or a `Data.p4k` install override (Control Panel), the -dialog that appears is the host's **native Linux** file picker. It hands -back paths like `/home/user/Music/foo.mp3` rather than the `C:\…` or -`Z:\…` form Wine would normally expose. +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: -The plugin compensates server-side: `WinePath.Translate` rewrites any -incoming Unix-style absolute path to `Z:\…` when running under Wine, -before the path is passed to `File.Exists`, `NAudio.AudioFileReader`, -or the P4K opener. Existing Windows-style paths (e.g. saved from a -prior Windows install of SCStreamDeck) are left untouched. +``` +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) +``` -The UI still displays the Linux-style path it captured — only the -behind-the-scenes I/O is rewritten. A future improvement would be a -custom Wine-aware file dialog rendered by the plugin itself; not in -scope here. +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 untouched. +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 `wine_get_version` | -| [`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 scan code → evdev keycode tables | -| [`InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/InputSimulatorPlus/WindowsInput/WindowsInputMessageDispatcher.cs) | 4-line patch that delegates to `LinuxInputBridge` when `WineDetector.IsWine` | -| [`PluginCore/Infrastructure/WinePath.cs`](https://github.com/Jarex985/SCStreamDeck/blob/main/PluginCore/Infrastructure/WinePath.cs) | Rewrites Linux paths from the native file picker to `Z:\` form for NAudio / `File.*` consumers | - -Two ancillary build adjustments make the project cross-build cleanly -from Linux to win-x64: - -- `InputSimulatorPlus/WindowsInput/WindowsInput.csproj` was converted - from the legacy MSBuild format to the SDK-style format targeting - `net8.0-windows`. The legacy project required a .NET Framework 4.8 - targeting pack that isn't shipped on Linux. -- `PluginCore/PluginCore.csproj` adds `true` - so the Windows Desktop reference pack is pulled from NuGet when - building on Linux. The setting is a no-op on Windows. - -Manifest change: `"linux"` is appended to the `OS` array in -`PluginCore/manifest.json` so OpenDeck spawns the plugin via Wine on -Linux hosts. +| [`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.