diff --git a/README.md b/README.md index 664f94fe..f5c99429 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The wheel, from the top going clockwise: Danger, Push, On My Way, All In, Assist - **Trigger key:** Alt, Ctrl, Shift, Win, Caps Lock, Mouse 4, Mouse 5 or any other key (on a Mac: Option, Control, Shift, Command, Mouse 4, Mouse 5 or any other key). A custom key still types when tapped, but its letter appears when the key comes up, and holding it alone doesn't repeat. - **Alt + click places a ping:** off by default, so ordinary Alt + click shortcuts keep working. The ping is the one in the centre of the wheel editor (Generic unless you change it). - **Pause in fullscreen apps:** on by default. A fullscreen game keeps every click — trigger + click does nothing there — and gestures resume on the desktop and on other monitors. Windowed games aren't detected, and with an auto-hiding taskbar only borderless fullscreen counts. Windows only for now. +- **App exceptions:** name apps (by process name, or captured from whatever is in front) where lolPing stands down entirely — gestures and hotkeys pass through, so Photoshop's Option + click colour picker and a Tab that toggles panels keep working. - **Enable / disable shortcut:** must include Ctrl, Alt or Win (⌃, ⌥ or ⌘ on a Mac). - **Pings and sound:** size, duration, volume, mute and the wheel's tick sound. **Keep my last ping on screen** leaves your newest ping up until your next one or until you pause lolPing, for example to mark where you stopped reading. - **Wheel:** adjust the size of both the ping and emote wheels, without changing placed pings or emotes. Drag any ping onto any slice, including Bait, Vision Cleared and the upside-down Enemy Missing, or onto the centre to make it the Alt + click ping. Click a ping to preview it. **Reset to default** brings back League's layout. diff --git a/README.zh-CN.md b/README.zh-CN.md index c8be35de..163f3dbf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,6 +90,7 @@ Mac 版需要 Apple 芯片(M1 或更新)的 Mac 和 macOS 13 或更新版本 - **触发键:** Alt、Ctrl、Shift、Win、Caps Lock、鼠标侧键 4/5,或任意其他按键(Mac 上:Option、Control、Shift、Command、鼠标侧键 4/5,或任意其他按键)。自定义按键单按时仍会输入字符,但字符在松开按键时才出现,单独按住也不会连续输入。 - **Alt + 单击发送信号:** 默认关闭,这样平时的 Alt + 单击快捷操作不受影响。发送的是轮盘编辑器中心的信号(默认是普通信号)。 - **全屏程序中暂停:** 默认开启。全屏游戏独占所有点击——触发键 + 单击原样交给程序——回到桌面或在其他显示器上手势恢复正常。窗口化游戏检测不到;任务栏自动隐藏时只识别无边框全屏。目前仅限 Windows。 +- **程序例外:** 指定一些程序(按进程名,或一键捕获当前前台程序),在这些程序里 lolPing 完全待机——手势和快捷键都原样传给程序,Photoshop 的 Option + 单击取色、切换工具面板的 Tab 都不受影响。 - **开关快捷键:** 必须包含 Ctrl、Alt 或 Win(Mac 上为 ⌃、⌥ 或 ⌘)。 - **信号与声音:** 大小、持续时间、音量、静音、轮盘提示音。打开**保留我最后一个信号**后,你最新的信号会一直留在屏幕上,直到你发出下一个信号或暂停 lolPing,比如用来标记读到哪里。 - **轮盘:** 可以调整信号和表情轮盘的大小,不影响已放出的信号或表情。把任意信号(包括诱饵、视野已清除和反向问号)拖到任意扇区,或拖到中心设为 Alt + 单击发送的信号。单击信号可预览。**恢复默认**会还原英雄联盟的布局。 diff --git a/docs/design.md b/docs/design.md index 15f770cc..e7c14d0f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -135,6 +135,7 @@ States: `Idle`, `Pending` (trigger held + button down, not yet moved past the th | Situation | Action | |---|---| | Disabled | Pass everything through; only the toggle hotkey is checked | +| The foreground app is on the skip list (`config`'s `skipApps`) | Stand down like a suspension: gestures and every hotkey but the toggle pass through. Matched on the app's process name, case-insensitively without a trailing `.exe`; an empty list never asks for the foreground app. | | Mouse down that would start a gesture, `gameCheck` on and the foreground window fullscreen on the clicked monitor (its rect contains the monitor; where the monitor reserves no work area, only caption-less windows count; desktop and taskbar excluded; Windows only) | Pass through; the fullscreen app keeps every click | | Left button down while trigger held | **Swallow**, record the point, go to `Pending` | | `Pending`, movement > `dragThresholdPx` | Send `wheelOpen{x,y}` with the *press* point, go to `Wheel` | @@ -416,10 +417,17 @@ Every member counts the ballots itself. Nobody announces a result. - `config` gains `textMods`, `textVk` and `panelVk`. A `textVk` or `panelVk` of 0 means "not live": the helper then leaves that key to other apps. The main process sends a real code only while lolPing is on, we are in a room, and the matching feature is on (`liveKeys` in `src/shared/features.ts`). - Events gain `{"type":"textRequested"}` and `{"type":"panel","show":bool}`. The main process reads the cursor position itself. - **Text hotkey:** while live it is swallowed, together with its key-up, and the helper sends `textRequested`. An app's own Alt+T (the Tools menu in Firefox, the Office ribbon) and the Mac's ⌥T character do not reach it. Alt is also the default ping trigger; Alt+T is a key combination and the wheel needs the mouse, so they do not conflict. The same holds for a custom trigger key — the text branch matches its chord exactly, modifiers included, before the trigger branch sees the key — so the settings let a text chord share a custom ping or emote key (issue #100). When Alt is released after a swallowed T, the helper injects the mask key (§5.2) so Windows does not open the menu bar. -- **Panel key (default Tab):** with no modifier held, the key down is captured at once and never replayed, and the helper sends `panel {show: true}`. Its key-up sends `panel {show: false}`. Auto-repeat is swallowed. These cases pass untouched: a key down with Ctrl, Alt, Shift, Win or ⌘ held (Alt+Tab, Ctrl+Tab, Shift+Tab), a fullscreen app in front with `gameCheck` on (Windows only, so League keeps its own scoreboard), lolPing paused, and no room or the feature off. +- **Panel key (default Tab):** with no modifier held, the key down is captured at once and never replayed, and the helper sends `panel {show: true}`. Its key-up sends `panel {show: false}`. Auto-repeat is swallowed. These cases pass untouched: a key down with Ctrl, Alt, Shift, Win or ⌘ held (Alt+Tab, Ctrl+Tab, Shift+Tab), a fullscreen app in front with `gameCheck` on (Windows only, so League keeps its own scoreboard), an app on the skip list (§App exceptions), lolPing paused, and no room or the feature off. - **Release paths:** if the configuration changes while the key is down (the room is left, lolPing is paused, the feature is turned off), the panel hides and the key-up is still swallowed, so the key never stays pressed. On Windows a watchdog hides the panel when a captured key has not repeated for 1.5 s, in case the key-up was lost. On macOS and Linux a held key does not reliably repeat, so the watchdog is off there and the panel waits for the key-up. - **The panel key is a deliberate exception to guarantee 1 in AGENTS.md** (input passes through), chosen by the maintainer. League shows its scoreboard the moment Tab goes down. Holding the key back to see whether it is a tap, and replaying it, would delay the panel or reorder input. So the key is taken at once and not given back. The exception is as small as it can be: only while in a room, only that one key, only with no modifier, never over a fullscreen game with `gameCheck` on, and the user can change the key or turn the panel off. The Settings page, both READMEs and this section say so. +### App exceptions in the helper + +- `config` gains `skipApps`: one comma-separated list of process names where lolPing stands down entirely — gestures and every hotkey but the toggle — for as long as one of them is the foreground app. The names are matched case-insensitively and without a trailing `.exe`. An empty list costs nothing: the foreground app is then never queried. +- The helper learns the foreground app's process image name per platform: `GetForegroundWindow` + `QueryFullProcessImageName` on Windows, cached per window so a press costs one `GetForegroundWindow`; `NSWorkspace`'s frontmost application on macOS, for which `hooks_mac.cpp` now compiles as Objective-C++. Linux has no list yet: its helper never skips. +- The settings window asks `{"type":"getForegroundApp"}` and the helper answers `{"type":"foregroundApp","name":"..."}`, for the capture button: the settings window minimizes for three seconds — minimizing hands focus to the next top-level window, so the app the user wants is the one in front — and the window is restored on top with the answer. The exception box also offers a dropdown: `{"type":"getAppList"}` is answered with `{"type":"appList","apps":[{name, desc, path}]}`, each window the user can switch to (visible, titled, not tool windows or shell surfaces on Windows; regular-policy apps on macOS), at most 64 — its process image name, the words the system shows for it (the file's FileDescription on Windows, the localised name on macOS, read only for this list), and the image path. The main process turns each path into the file's own icon with Electron's `app.getFileIcon` (`src/main/appIcons.ts` caches one fetch per path); where the platform has no icon the option shows the name only. A name typed by hand is matched against those display words first (`resolveSkipApp`), so the "网易UU远程" an IME offers adds GameViewer, not a name that never matches. Asked only when the dropdown opens, never in the hooks. The helper's `ready` version becomes 5. +- The setting is `skipApps` (default `[]`, at most 32): process names as stored, lower-case without `.exe`. In an excepted app the wheel's own Tab and Ctrl+Alt chords reach it untouched, which is the point: Photoshop's Option + click colour picker and a Tab that toggles tool panels stop being taken. + ### Windows that take clicks without focus (`src/main/panelWindows.ts`) - The roster panel and the vote widgets take clicks but not focus: `focusable: false`, shown with `showInactive()`, always on top at the level of the overlay. On macOS the window is `type: 'panel'`. They are created when needed, and each has its own preload with only what it needs. diff --git a/docs/media/app-exceptions.png b/docs/media/app-exceptions.png new file mode 100644 index 00000000..07a492f0 Binary files /dev/null and b/docs/media/app-exceptions.png differ diff --git a/native/hook-helper/build.cmd b/native/hook-helper/build.cmd index f75c523d..2587ee9e 100644 --- a/native/hook-helper/build.cmd +++ b/native/hook-helper/build.cmd @@ -14,7 +14,7 @@ call "%VSDIR%\VC\Auxiliary\Build\vcvars64.bat" >nul || exit /b 1 cd /d "%~dp0" if not exist build\obj mkdir build\obj set FLAGS=/nologo /std:c++20 /EHsc /W4 /WX /MT /DUNICODE /D_UNICODE /Fobuild\obj\ -cl %FLAGS% /O2 src\main_win.cpp src\hooks_win.cpp src\simulate.cpp src\commands.cpp src\output.cpp src\json.cpp src\decision.cpp /Fe:build\hook-helper.exe /link user32.lib || exit /b 1 +cl %FLAGS% /O2 src\main_win.cpp src\hooks_win.cpp src\simulate.cpp src\commands.cpp src\output.cpp src\json.cpp src\decision.cpp /Fe:build\hook-helper.exe /link user32.lib version.lib || exit /b 1 cl %FLAGS% /Od tests\helper_tests.cpp src\decision.cpp src\commands.cpp src\macinput.cpp src\json.cpp src\output.cpp /Fe:build\helper_tests.exe || exit /b 1 cl %FLAGS% /O2 src\context_main_win.cpp src\context_capture_win.cpp src\context_text_win.cpp src\context_write_win.cpp src\context.cpp src\json.cpp /Fe:build\context-helper.exe /link user32.lib gdi32.lib ole32.lib oleaut32.lib windowscodecs.lib windowsapp.lib uiautomationcore.lib shell32.lib dwmapi.lib || exit /b 1 cl %FLAGS% /Od tests\context_tests.cpp src\context.cpp src\json.cpp /Fe:build\context_tests.exe || exit /b 1 diff --git a/native/hook-helper/build.sh b/native/hook-helper/build.sh index 46b4af57..4a95fc89 100755 --- a/native/hook-helper/build.sh +++ b/native/hook-helper/build.sh @@ -8,9 +8,10 @@ case "$(uname -s)" in Darwin) CXX=${CXX:-clang++} FLAGS="-std=c++20 -arch arm64 -mmacosx-version-min=13.0 -Wall -Wextra" - "$CXX" $FLAGS -O2 src/main_posix.cpp src/hooks_mac.cpp src/macinput.cpp src/simulate.cpp src/commands.cpp \ + # hooks_mac.cpp uses AppKit's NSWorkspace, so it compiles as Objective-C++. + "$CXX" $FLAGS -O2 -x objective-c++ src/hooks_mac.cpp -x c++ src/main_posix.cpp src/macinput.cpp src/simulate.cpp src/commands.cpp \ src/output.cpp src/json.cpp src/decision.cpp \ - -framework ApplicationServices -framework CoreFoundation -o build/hook-helper + -framework ApplicationServices -framework CoreFoundation -framework Cocoa -o build/hook-helper "$CXX" $FLAGS -fobjc-arc -O2 src/context_main_posix.cpp src/context_capture_mac.mm src/context_text_mac.mm \ src/context_write_mac.mm src/context.cpp src/json.cpp -framework ScreenCaptureKit -framework CoreGraphics \ -framework ImageIO -framework AppKit -framework Carbon \ @@ -28,8 +29,9 @@ esac -o build/helper_tests "$CXX" $FLAGS ${CPPFLAGS:-} -O0 tests/context_tests.cpp src/context.cpp src/json.cpp -o build/context_tests if [ "$(uname -s)" = Darwin ]; then - "$CXX" $FLAGS -O0 tests/hooks_mac_tests.cpp src/decision.cpp src/commands.cpp src/macinput.cpp src/json.cpp src/output.cpp \ - -framework ApplicationServices -framework CoreFoundation -o build/hooks_mac_tests + # hooks_mac_tests.cpp includes hooks_mac.cpp textually, so it needs the same Objective-C++ flag. + "$CXX" $FLAGS -O0 -x objective-c++ tests/hooks_mac_tests.cpp -x c++ src/decision.cpp src/commands.cpp src/macinput.cpp src/json.cpp src/output.cpp \ + -framework ApplicationServices -framework CoreFoundation -framework Cocoa -o build/hooks_mac_tests echo "Built build/hook-helper, build/context-helper, build/helper_tests, build/context_tests and build/hooks_mac_tests" else echo "Built build/hook-helper, build/helper_tests and build/context_tests" diff --git a/native/hook-helper/src/commands.cpp b/native/hook-helper/src/commands.cpp index 74b72dea..ae613f24 100644 --- a/native/hook-helper/src/commands.cpp +++ b/native/hook-helper/src/commands.cpp @@ -75,6 +75,7 @@ Command parseCommand(const std::string& line, const Config& current) { cfg.triggerVk = static_cast(getInt(m, "triggerVk", cfg.triggerVk)); cfg.clickPing = getBool(m, "clickPing", cfg.clickPing); cfg.gameCheck = getBool(m, "gameCheck", cfg.gameCheck); + if (m.count("skipApps") != 0) cfg.skipApps = m["skipApps"]; if (m.count("emoteTrigger") != 0 && !parseEmoteTrigger(m["emoteTrigger"], cfg.emoteTrigger)) return c; cfg.emoteTriggerVk = static_cast(getInt(m, "emoteTriggerVk", cfg.emoteTriggerVk)); cfg.emoteClick = getBool(m, "emoteClick", cfg.emoteClick); @@ -101,6 +102,10 @@ Command parseCommand(const std::string& line, const Config& current) { } else if (type == "staleWatch") { c.kind = Command::Kind::StaleWatch; c.flag = getBool(m, "on", false); + } else if (type == "getForegroundApp") { + c.kind = Command::Kind::ForegroundApp; + } else if (type == "getAppList") { + c.kind = Command::Kind::AppList; } else if (type == "shutdown") { c.kind = Command::Kind::Shutdown; } else if (type == "sim") { diff --git a/native/hook-helper/src/commands.h b/native/hook-helper/src/commands.h index ea7f1b8e..c60e47ed 100644 --- a/native/hook-helper/src/commands.h +++ b/native/hook-helper/src/commands.h @@ -7,7 +7,7 @@ namespace lp { struct Command { - enum class Kind { Invalid, Config, SetEnabled, Suspend, Shutdown, SimEvent, SimTick, StaleWatch } kind = Kind::Invalid; + enum class Kind { Invalid, Config, SetEnabled, Suspend, Shutdown, StaleWatch, ForegroundApp, AppList, SimEvent, SimTick } kind = Kind::Invalid; Config config; // Config bool flag = false; // SetEnabled / Suspend / StaleWatch InputEvent event; // SimEvent diff --git a/native/hook-helper/src/decision.cpp b/native/hook-helper/src/decision.cpp index ceaeb128..a1f86cbc 100644 --- a/native/hook-helper/src/decision.cpp +++ b/native/hook-helper/src/decision.cpp @@ -1,5 +1,6 @@ #include "decision.h" +#include #include namespace lp { @@ -9,6 +10,20 @@ constexpr uint32_t kVkShift = 0x10, kVkCtrl = 0x11, kVkAlt = 0x12; constexpr uint32_t kVkLShift = 0xA0, kVkRShift = 0xA1, kVkLCtrl = 0xA2, kVkRCtrl = 0xA3; constexpr uint32_t kVkLAlt = 0xA4, kVkRAlt = 0xA5, kVkLWin = 0x5B, kVkRWin = 0x5C, kVkEsc = 0x1B; +// A process name as the skip list matches it: lower-case, without a trailing ".exe". +std::string appStem(std::string s) { + size_t start = 0; + while (start < s.size() && (s[start] == ' ' || s[start] == '\t')) ++start; + size_t end = s.size(); + while (end > start && (s[end - 1] == ' ' || s[end - 1] == '\t')) --end; + s = s.substr(start, end - start); + for (char& ch : s) { + if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A'; + } + if (s.size() > 4 && s.compare(s.size() - 4, 4, ".exe") == 0) s.resize(s.size() - 4); + return s; +} + uint32_t modBit(uint32_t vk) { switch (vk) { case kVkCtrl: @@ -231,6 +246,16 @@ Result Decision::setConfig(const Config& c) { enabled_ = c.enabled; spotWhileHeld_ = {}; resetDoubleTap(); + skipStems_.clear(); + size_t pos = 0; + while (pos < c.skipApps.size()) { + const size_t comma = c.skipApps.find(',', pos); + const std::string stem = appStem(c.skipApps.substr(pos, comma == std::string::npos ? std::string::npos : comma - pos)); + if (!stem.empty() && std::find(skipStems_.begin(), skipStems_.end(), stem) == skipStems_.end()) + skipStems_.push_back(stem); + if (comma == std::string::npos) break; + pos = comma + 1; + } return r; } @@ -262,6 +287,17 @@ Result Decision::setStaleWatch(bool on) { void Decision::setFullscreenProbe(std::function probe) { fullscreenProbe_ = std::move(probe); } +void Decision::setForegroundApp(std::function fg) { foregroundApp_ = std::move(fg); } + +// The skip list costs one supplier call per gesture or hotkey press, and none at all while it is empty. +bool Decision::inSkippedApp() const { + if (skipStems_.empty() || !foregroundApp_) return false; + const std::string name = foregroundApp_(); + if (name.empty()) return false; + const std::string stem = appStem(name); + return std::find(skipStems_.begin(), skipStems_.end(), stem) != skipStems_.end(); +} + Result Decision::releasePanel() { Result r; if (panelDown_ == 0) return r; diff --git a/native/hook-helper/src/decision.h b/native/hook-helper/src/decision.h index f16fd1a2..9d4ae462 100644 --- a/native/hook-helper/src/decision.h +++ b/native/hook-helper/src/decision.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace lp { @@ -38,6 +39,9 @@ struct Config { // Fullscreen apps (games) keep every click. Off here so a config without the field changes nothing; // the app's setting defaults it on. bool gameCheck = false; + // Apps where lolPing stands down entirely, as one comma-separated list of process names ("Photoshop.exe, + //idea64"). The stems are matched case-insensitively without the .exe. Empty: no app is skipped. + std::string skipApps; Trigger emoteTrigger = Trigger::None; // the emote wheel's uint32_t emoteTriggerVk = 0; // only for Trigger::CustomVk bool emoteClick = false; @@ -105,6 +109,9 @@ class Decision { Result tick(uint64_t nowMs); // Per platform: does a fullscreen app own the screen under the click at (x, y)? Unset: gameCheck never fires. void setFullscreenProbe(std::function probe); + // Per platform: the foreground app's process image name ("Photoshop.exe"), '' when it can't be read. + // Used for the skipped-app stand-down. Unset: nothing is ever skipped. + void setForegroundApp(std::function fg); // Windows opens the menu bar / Start menu on a lone Alt / Win key-up, so a swallowed press while one is held // gets a mask key before that key-up. macOS has no such behaviour: its hook layer turns this off. void setMaskMenuKeys(bool on) { maskMenuKeys_ = on; } @@ -134,7 +141,9 @@ class Decision { bool click; // trigger + click places a ping / emote instead of replaying the click }; - bool active() const { return enabled_ && !suspended_; } + bool active() const { return enabled_ && !suspended_ && !inSkippedApp(); } + // The foreground app's stem is on the skip list. Cheap when no list is set: the supplier is not consulted. + bool inSkippedApp() const; uint32_t mods() const; Spec spec(WheelKind k) const; bool held(WheelKind k) const; @@ -171,6 +180,8 @@ class Decision { bool enabled_ = true; bool suspended_ = false; std::function fullscreenProbe_; + std::function foregroundApp_; + std::vector skipStems_; // cfg_.skipApps split and normalised, refreshed by setConfig State state_ = State::Idle; std::array keyDown_{}; std::set swallowKeyUps_; diff --git a/native/hook-helper/src/hooks_mac.cpp b/native/hook-helper/src/hooks_mac.cpp index 4fa5a371..b394f529 100644 --- a/native/hook-helper/src/hooks_mac.cpp +++ b/native/hook-helper/src/hooks_mac.cpp @@ -1,7 +1,9 @@ // macOS input layer: a CGEventTap feeding the shared Decision logic. Needs Accessibility permission. +#import #include #include +#include #include #include #include @@ -45,6 +47,37 @@ bool isKeyDown(uint32_t vk) { return code >= 0 && CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState, static_cast(code)); } +// The frontmost app's process name ("Photoshop"), or '' when there is none. The run loop serving the tap is this +// process's main thread, which is where NSWorkspace answers from, so the query is safe in the tap callback. +std::string foregroundAppName() { + @autoreleasepool { + NSRunningApplication* app = [[NSWorkspace sharedWorkspace] frontmostApplication]; + NSString* name = app.localizedName; + return name != nil ? std::string(name.UTF8String) : std::string(); + } +} + +// The regular apps the user can switch to, by process name and bundle path, at most 64. Asked only by the settings page. +std::vector runningApps() { + @autoreleasepool { + std::vector apps; + for (NSRunningApplication* app in [[NSWorkspace sharedWorkspace] runningApplications]) { + if (apps.size() >= 64) break; + if (app.activationPolicy != NSApplicationActivationPolicyRegular) continue; + NSString* name = app.localizedName; + if (name == nil) continue; + AppEntry entry; + entry.name = name.UTF8String; + NSString* path = app.bundleURL.path; + if (path != nil) entry.path = path.UTF8String; + if (std::none_of(apps.begin(), apps.end(), [&](const AppEntry& a) { return a.name == entry.name; })) + apps.push_back(entry); + } + std::sort(apps.begin(), apps.end(), [](const AppEntry& a, const AppEntry& b) { return a.name < b.name; }); + return apps; + } +} + CGPoint cursorLocation() { CGEventRef e = CGEventCreate(nullptr); const CGPoint p = CGEventGetLocation(e); @@ -205,6 +238,8 @@ void handleCommand(const std::string& text) { case Command::Kind::SetEnabled: apply(g_decision->setEnabled(c.flag)); break; case Command::Kind::Suspend: apply(g_decision->setSuspended(c.flag)); break; case Command::Kind::StaleWatch: apply(g_decision->setStaleWatch(c.flag)); break; + case Command::Kind::ForegroundApp: g_out->foregroundApp(foregroundAppName()); break; + case Command::Kind::AppList: g_out->appList(runningApps()); break; case Command::Kind::Shutdown: CFRunLoopStop(CFRunLoopGetCurrent()); break; default: g_out->error("invalid command: " + text); break; } @@ -217,6 +252,7 @@ int runHooks(Output& out) { decision.setMaskMenuKeys(false); // The repeat watchdog only guards lost key-ups; macOS repeat delay and the repeat setting make repeats unreliable. decision.setPanelRepeatGap(0); + decision.setForegroundApp(foregroundAppName); g_decision = &decision; g_out = &out; diff --git a/native/hook-helper/src/hooks_win.cpp b/native/hook-helper/src/hooks_win.cpp index 4c7416bb..efca6e94 100644 --- a/native/hook-helper/src/hooks_win.cpp +++ b/native/hook-helper/src/hooks_win.cpp @@ -2,7 +2,9 @@ #define WIN32_LEAN_AND_MEAN #include #include +#include +#include #include #include #include @@ -38,6 +40,125 @@ bool isExtendedVk(uint32_t vk) { bool isKeyDown(uint32_t vk) { return (GetAsyncKeyState(static_cast(vk)) & 0x8000) != 0; } +// One process image's entry: its file name and full path as the file system names them, UTF-8. Empty when the +// image can't be read. QueryFullProcessImageName crosses a process boundary but sends no messages. +AppEntry entryFromPath(const std::wstring& path) { + const int bytes = WideCharToMultiByte(CP_UTF8, 0, path.data(), static_cast(path.size()), nullptr, 0, nullptr, nullptr); + if (bytes <= 0) return {}; + std::string utf8(static_cast(bytes), '\0'); + WideCharToMultiByte(CP_UTF8, 0, path.data(), static_cast(path.size()), utf8.data(), bytes, nullptr, nullptr); + const size_t slash = utf8.find_last_of("\\/"); + AppEntry out; + out.path = utf8; + out.name = slash == std::string::npos ? utf8 : utf8.substr(slash + 1); + return out; +} + +AppEntry processOf(HWND window) { + DWORD pid = 0; + if (GetWindowThreadProcessId(window, &pid) == 0 || pid == 0) return {}; + const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (process == nullptr) return {}; + wchar_t wide[MAX_PATH]; + DWORD size = MAX_PATH; + AppEntry out; + if (QueryFullProcessImageNameW(process, 0, wide, &size) && size > 0) out = entryFromPath(std::wstring(wide, size)); + CloseHandle(process); + return out; +} + +// The foreground window's process image name ("Photoshop.exe"), or '' when it can't be read. The query only +// runs when the foreground window changed since the last call, so the per-event cost is one GetForegroundWindow. +std::string foregroundAppName() { + static HWND last = nullptr; + static std::string name; + const HWND fg = GetForegroundWindow(); + if (fg == nullptr) { + last = nullptr; + name.clear(); + return name; + } + if (fg == last) return name; + last = fg; + name = processOf(fg).name; + return name; +} + +// The file's FileDescription ("网易UU远程" for GameViewer.exe): the words the system and IMEs show for an app, which +// is what a user types. Only for the settings page's app list, never in the hooks. +std::string fileDescription(const std::string& pathUtf8) { + if (pathUtf8.empty()) return {}; + const int wideLen = MultiByteToWideChar(CP_UTF8, 0, pathUtf8.data(), static_cast(pathUtf8.size()), nullptr, 0); + if (wideLen <= 0) return {}; + std::wstring path(static_cast(wideLen), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, pathUtf8.data(), static_cast(pathUtf8.size()), path.data(), wideLen); + DWORD handle = 0; + const DWORD size = GetFileVersionInfoSizeW(path.c_str(), &handle); + if (size == 0) return {}; + std::vector block(size); + if (!GetFileVersionInfoW(path.c_str(), 0, size, block.data())) return {}; + struct Lang { WORD language, codepage; }* langs = nullptr; + UINT langCount = 0; + if (!VerQueryValueW(block.data(), L"\\VarFileInfo\\Translation", reinterpret_cast(&langs), &langCount) || + langCount < sizeof(Lang) || langs == nullptr) + return {}; + wchar_t key[64]; + swprintf_s(key, L"\\StringFileInfo\\%04x%04x\\FileDescription", langs[0].language, langs[0].codepage); + wchar_t* value = nullptr; + UINT valueLen = 0; + if (!VerQueryValueW(block.data(), key, reinterpret_cast(&value), &valueLen) || value == nullptr) return {}; + const std::wstring wide(value, valueLen > 0 ? valueLen - 1 : 0); // the length counts the NUL + const int bytes = WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast(wide.size()), nullptr, 0, nullptr, nullptr); + if (bytes <= 0) return {}; + std::string utf8(static_cast(bytes), '\0'); + WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast(wide.size()), utf8.data(), bytes, nullptr, nullptr); + return utf8; +} + +// Windows' own hosts and brokers: svchost farms hold no windows, the window hosts hold them for other apps - +// nobody works inside one, and an exception entry would silently do nothing. Same idea as the shell surfaces +// the fullscreen probe skips. +bool isSystemHost(const std::string& name) { + static const char* const hosts[] = { + "ApplicationFrameHost.exe", "TextInputHost.exe", "ShellExperienceHost.exe", "SearchHost.exe", + "StartMenuExperienceHost.exe", "RuntimeBroker.exe", "dllhost.exe", "taskhostw.exe", "Widgets.exe", + "WidgetService.exe", "svchost.exe", "csrss.exe", "smss.exe", "services.exe", "lsass.exe", "wininit.exe", + "winlogon.exe", "dwm.exe", "conhost.exe", "OpenConsole.exe", "fontdrvhost.exe", "audiodg.exe", "spoolsv.exe", + "WudfHost.exe", "MsMpEng.exe", "NisSrv.exe", "SecurityHealthService.exe", "SecurityHealthSystray.exe", + "SearchIndexer.exe", "msedgewebview2.exe", "steamwebhelper.exe", "hook-helper.exe", + }; + for (const char* host : hosts) { + if (_stricmp(name.c_str(), host) == 0) return true; + } + return false; +} + +// The apps behind the windows the user can switch to: visible, titled, not a tool window or a shell surface, +// deduplicated by name, at most 64. Run only when the settings page asks, never in the hooks. +BOOL CALLBACK collectAppWindow(HWND window, LPARAM lp) { + auto& apps = *reinterpret_cast*>(lp); + if (apps.size() >= 64) return FALSE; + if (!IsWindowVisible(window)) return TRUE; + if (GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) return TRUE; + if (GetWindowTextLengthW(window) <= 0) return TRUE; + wchar_t cls[64]; + if (GetClassNameW(window, cls, 64) != 0 && + (lstrcmpiW(cls, L"Progman") == 0 || lstrcmpiW(cls, L"WorkerW") == 0 || lstrcmpiW(cls, L"Shell_TrayWnd") == 0)) + return TRUE; + const AppEntry app = processOf(window); + if (app.name.empty() || isSystemHost(app.name)) return TRUE; + if (std::none_of(apps.begin(), apps.end(), [&](const AppEntry& a) { return a.name == app.name; })) + apps.push_back(app); + return TRUE; +} + +std::vector runningApps() { + std::vector apps; + EnumWindows(collectAppWindow, reinterpret_cast(&apps)); + for (auto& app : apps) app.desc = fileDescription(app.path); + return apps; +} + // True when the click at (x, y) lands on a monitor that the foreground window covers whole: a game, or any // fullscreen app, owns that screen, so trigger+click belongs to it, not to a wheel. A click on another // monitor still gestures. "Covers" means the window rect contains the monitor rect: borderless fullscreen @@ -229,6 +350,7 @@ int runHooks(Output& out) { g_decision = &decision; g_out = &out; decision.setFullscreenProbe(clickIsOnFullscreenApp); + decision.setForegroundApp(foregroundAppName); const DWORD threadId = GetCurrentThreadId(); g_hookThreadId = threadId; MSG msg; @@ -275,6 +397,8 @@ int runHooks(Output& out) { case Command::Kind::SetEnabled: apply(decision.setEnabled(c.flag)); break; case Command::Kind::Suspend: apply(decision.setSuspended(c.flag)); break; case Command::Kind::StaleWatch: apply(decision.setStaleWatch(c.flag)); break; + case Command::Kind::ForegroundApp: g_out->foregroundApp(foregroundAppName()); break; + case Command::Kind::AppList: g_out->appList(runningApps()); break; case Command::Kind::Shutdown: PostQuitMessage(0); break; default: out.error("invalid command: " + *text); break; } diff --git a/native/hook-helper/src/output.cpp b/native/hook-helper/src/output.cpp index f62f1899..fccf2931 100644 --- a/native/hook-helper/src/output.cpp +++ b/native/hook-helper/src/output.cpp @@ -45,8 +45,23 @@ void Output::line(std::string s) { void Output::emit(const Emit& e) { line(formatEmit(e)); } -// Version 6: the config takes askDoubleTap; askWords and scroll events and the staleWatch command are new. -void Output::ready() { line("{\"type\":\"ready\",\"version\":6}"); } +// Version 7: the config takes the skipped-app list, and the foregroundApp and appList events +// and the getForegroundApp and getAppList commands are new. +void Output::ready() { line("{\"type\":\"ready\",\"version\":7}"); } + +void Output::foregroundApp(const std::string& name) { + line("{\"type\":\"foregroundApp\",\"name\":\"" + jsonEscape(name) + "\"}"); +} + +void Output::appList(const std::vector& apps) { + std::string out = "{\"type\":\"appList\",\"apps\":["; + for (size_t i = 0; i < apps.size(); ++i) { + if (i != 0) out += ","; + out += "{\"name\":\"" + jsonEscape(apps[i].name) + "\",\"desc\":\"" + jsonEscape(apps[i].desc) + "\",\"path\":\"" + + jsonEscape(apps[i].path) + "\"}"; + } + line(out + "]}"); +} void Output::error(const std::string& message) { line("{\"type\":\"error\",\"message\":\"" + jsonEscape(message) + "\"}"); } diff --git a/native/hook-helper/src/output.h b/native/hook-helper/src/output.h index a6c2241e..0a976cab 100644 --- a/native/hook-helper/src/output.h +++ b/native/hook-helper/src/output.h @@ -5,11 +5,20 @@ #include #include #include +#include #include "decision.h" namespace lp { +/** One app the user can switch to: its process image's file name, the words the system shows for it (the file's + * description on Windows, the localised name on macOS; empty when neither is there), and the image's path. */ +struct AppEntry { + std::string name; + std::string desc; + std::string path; +}; + // Writes protocol lines on a background thread so the hook thread never blocks // on a full stdout pipe. When the queue is full, new lines are dropped. class Output { @@ -24,6 +33,8 @@ class Output { void line(std::string s); void emit(const Emit& e); void ready(); + void foregroundApp(const std::string& name); + void appList(const std::vector& apps); void error(const std::string& message); void error(const std::string& code, const std::string& message); // machine-readable code, e.g. "noAccess" void stop(); // drains the queue, then joins the writer thread diff --git a/native/hook-helper/tests/helper_tests.cpp b/native/hook-helper/tests/helper_tests.cpp index 412472c3..3e65fd06 100644 --- a/native/hook-helper/tests/helper_tests.cpp +++ b/native/hook-helper/tests/helper_tests.cpp @@ -371,6 +371,111 @@ static void test_fullscreen_probe_ignored_when_disabled() { CHECK(findEmit(r, Emit::Kind::Click) != nullptr); } +// An app on the skip list stands down entirely: its clicks and its chords (the text hotkey, the panel key) +// reach it untouched, while the enable/disable toggle still works there as the way back out. +static void test_skipped_app_stands_down() { + Config c; + c.trigger = Trigger::Alt; + c.clickPing = true; + c.skipApps = "Photoshop.exe,idea64"; + c.toggleMods = ModCtrl | ModAlt; + c.toggleVk = KEY_P; + c.textMods = ModAlt; + c.textVk = KEY_T; + c.panelVk = TAB; + Decision d; + d.setConfig(c); + std::string front = "Photoshop.exe"; + d.setForegroundApp([&front] { return front; }); + + d.onEvent(kd(ALT)); + Result r = d.onEvent(md(Btn::Left, 100, 100)); + CHECK(!r.swallow); // Photoshop keeps the click + CHECK(!findEmit(r, Emit::Kind::Click)); + CHECK(d.state() == S::Idle); + CHECK(!d.onEvent(mu(Btn::Left, 100, 100)).swallow); + d.onEvent(ku(ALT)); + + // Its chords are its own again: the text hotkey passes untouched. + d.onEvent(kd(ALT)); + r = d.onEvent(kd(KEY_T)); + CHECK(!r.swallow); + CHECK(!findEmit(r, Emit::Kind::TextRequested)); + d.onEvent(ku(KEY_T)); + d.onEvent(ku(ALT)); + // The panel key too. + r = d.onEvent(kd(TAB)); + CHECK(!r.swallow); + CHECK(!findEmit(r, Emit::Kind::Panel)); + d.onEvent(ku(TAB)); + + // Matching ignores case and the .exe suffix; another app gets gestures back. + front = "Explorer.EXE"; + d.onEvent(kd(ALT)); + r = d.onEvent(md(Btn::Left, 100, 100)); + CHECK(r.swallow); + r = d.onEvent(mu(Btn::Left, 100, 100)); + CHECK(r.swallow); + CHECK(findEmit(r, Emit::Kind::Click) != nullptr); + d.onEvent(ku(ALT)); + + // The toggle still answers in the skipped app: it is the way back out. + front = "idea64"; + d.onEvent(kd(ALT)); + d.onEvent(kd(CTRL)); + r = d.onEvent(kd(KEY_P)); + CHECK(r.swallow); + CHECK(findEmit(r, Emit::Kind::Toggled) != nullptr); + d.onEvent(ku(KEY_P)); + d.onEvent(ku(CTRL)); + d.onEvent(ku(ALT)); +} + +// With no apps on the skip list, the foreground app is never queried. +static void test_skip_apps_empty_never_queries() { + Config c; + c.trigger = Trigger::Alt; + c.clickPing = true; + Decision d; + d.setConfig(c); + int consulted = 0; + d.setForegroundApp([&consulted] { ++consulted; return "photoshop"; }); + d.onEvent(kd(ALT)); + Result r = d.onEvent(md(Btn::Left, 100, 100)); + CHECK(r.swallow); + CHECK(consulted == 0); + r = d.onEvent(mu(Btn::Left, 100, 100)); + CHECK(r.swallow); + CHECK(findEmit(r, Emit::Kind::Click) != nullptr); +} + +// Adding and removing an app by config takes effect at once, without touching the supplier. +static void test_skip_apps_live_update() { + Config c; + c.trigger = Trigger::Alt; + Decision d; + d.setConfig(c); + d.setForegroundApp([] { return "photoshop"; }); + d.onEvent(kd(ALT)); + CHECK(d.onEvent(md(Btn::Left, 100, 100)).swallow); // not on the list yet + d.onEvent(mu(Btn::Left, 100, 100)); + d.onEvent(ku(ALT)); + + Config withApp = c; + withApp.skipApps = "photoshop"; + d.setConfig(withApp); + d.onEvent(kd(ALT)); + CHECK(!d.onEvent(md(Btn::Left, 100, 100)).swallow); + d.onEvent(mu(Btn::Left, 100, 100)); + d.onEvent(ku(ALT)); + + d.setConfig(c); // the list is empty again + d.onEvent(kd(ALT)); + CHECK(d.onEvent(md(Btn::Left, 100, 100)).swallow); + d.onEvent(mu(Btn::Left, 100, 100)); + d.onEvent(ku(ALT)); +} + static void test_alt_up_after_gesture_is_masked() { Decision d; openWheel(d); @@ -1386,6 +1491,10 @@ static void test_parse_config_panel_key() { CHECK(c.kind == Command::Kind::Config && c.config.panelVk == 9); const Command keep = parseCommand(R"({"type":"config"})", c.config); CHECK(keep.config.panelVk == 9); + const Command front = parseCommand(R"({"type":"getForegroundApp"})", c.config); + CHECK(front.kind == Command::Kind::ForegroundApp); + const Command list = parseCommand(R"({"type":"getAppList"})", c.config); + CHECK(list.kind == Command::Kind::AppList); } static void test_json_parses_flat_objects() { @@ -1967,6 +2076,9 @@ int main() { test_win_tab_clicks_pass_through_and_sync_clears_flag(); test_fullscreen_game_keeps_the_click(); test_fullscreen_probe_ignored_when_disabled(); + test_skipped_app_stands_down(); + test_skip_apps_empty_never_queries(); + test_skip_apps_live_update(); test_toggle_hotkey_disables_and_enables(); test_hotkey_autorepeat_toggles_once(); test_hotkey_ignored_while_suspended(); diff --git a/src/main/appIcons.ts b/src/main/appIcons.ts new file mode 100644 index 00000000..abce142a --- /dev/null +++ b/src/main/appIcons.ts @@ -0,0 +1,33 @@ +/** + * An app's icon as a PNG data URL, or null when there is none worth showing: the platform read nothing, or the + * file only carries a copy of the shell's default program icon (an exe built without an icon embeds one anyway). + * `defaultPath` names a file that has no icon of its own, fetched once as that reference. + */ +export class AppIcons { + private readonly cache = new Map>(); + private readonly defaultIcon: Promise; + + constructor(private readonly readFileIcon: (path: string) => Promise, defaultPath?: string) { + this.defaultIcon = defaultPath ? this.fetch(defaultPath) : Promise.resolve(null); + } + + /** One fetch per path, shared by every caller: an icon doesn't change while the app runs. */ + icon(path: string): Promise { + if (path === '') return Promise.resolve(null); + let cached = this.cache.get(path); + if (!cached) { + cached = this.read(path); + this.cache.set(path, cached); + } + return cached; + } + + private async read(path: string): Promise { + const [icon, fallback] = await Promise.all([this.fetch(path), this.defaultIcon]); + return icon !== null && icon !== fallback ? icon : null; + } + + private fetch(path: string): Promise { + return this.readFileIcon(path).catch(() => null); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index fe79c794..56d2e6bb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import type { HistoryState, MateAction, MateView } from '../shared/teammateIpc'; import { parseTextInput } from '../shared/textCommands'; import { updateErrorText, type UpdateState } from '../shared/update'; import { registerAppScheme, serveAppScheme } from './appProtocol'; +import { AppIcons } from './appIcons'; import { resolveTarget, toShared } from './displayNumbers'; import { EmoteCache } from './emoteCache'; import { EmoteLibrary, resolveEmoteArt } from './emoteLibrary'; @@ -241,6 +242,17 @@ function start(): void { ); overlays.start(); const bridge = new InputBridge({ command: helperExePath() }); + // An image file's own icon where the platform can read one (Windows); anywhere else this settles null. The small + // size is native 16px: what the dropdown shows, without a blurry downscale from 32. ApplicationFrameHost.exe has + // no icon of its own, so its icon is the system's default program icon - the copy an icon-less exe embeds. + // macOS: app.getFileIcon hands back the same generic tile for every bundle (observed on macOS 26), so the icon + // comes from the thumbnail generator instead, which draws the app's real one. + const appIcons = new AppIcons(async (path) => { + const image = IS_MAC + ? await nativeImage.createThumbnailFromPath(path, { width: 32, height: 32 }) + : await app.getFileIcon(path, { size: 'small' }); + return image.isEmpty() ? null : image.toDataURL(); + }, 'C:\\Windows\\System32\\ApplicationFrameHost.exe'); // Updates: only in an installed build, or in a development build pointed at a test server with LOLPING_UPDATE_TEST_URL. const testUrl = process.env.LOLPING_UPDATE_TEST_URL || undefined; @@ -1131,6 +1143,30 @@ function start(): void { capturing = on; syncSuspended(); }, + // The app-exception list: the capture button (the settings window hides, so the app the user wants is the one + // left in front) and everything the user can switch to (the dropdown). Icons come from the image file itself + // where the platform can read one; otherwise the dropdown shows the name only. + appList: async () => { + const apps = await bridge.ask({ type: 'getAppList' }, (ev) => (ev.type === 'appList' ? ev.apps : null), []); + return Promise.all(apps.map(async (a) => ({ ...a, icon: await appIcons.icon(a.path) }))); + }, + captureForegroundApp: async () => { + const win = settingsWindow(); + // Minimizing hands focus to the next top-level window, so the app the user wants is simply in front; + // when the answer is in, the window comes back on top. + win?.minimize(); + await new Promise((resolve) => setTimeout(resolve, 3000)); + const name = await bridge.ask( + { type: 'getForegroundApp' }, + (ev) => (ev.type === 'foregroundApp' ? ev.name : null), + null, + ); + if (win && !win.isDestroyed()) { + win.restore(); + win.focus(); + } + return name; + }, openAccessibility: () => { if (!IS_MAC) return; systemPreferences.isTrustedAccessibilityClient(true); // adds lolPing to the list and shows the system prompt diff --git a/src/main/inputBridge.ts b/src/main/inputBridge.ts index 358e359c..f0988129 100644 --- a/src/main/inputBridge.ts +++ b/src/main/inputBridge.ts @@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { createInterface } from 'node:readline'; import { - HELPER_EXIT_NO_ACCESS, parseHelperLine, serializeCommand, type HelperCommand, type HelperStatus, + HELPER_EXIT_NO_ACCESS, parseHelperLine, serializeCommand, type HelperCommand, type HelperEvent, type HelperStatus, } from '../shared/protocol'; import { RestartPolicy } from './restartPolicy'; @@ -25,6 +25,7 @@ export class InputBridge extends EventEmitter { private lastConfig: HelperCommand | null = null; private restartTimer: NodeJS.Timeout | null = null; private current: HelperStatus = 'starting'; + private questions: ((ev: HelperEvent) => boolean)[] = []; private readonly policy: RestartPolicy; private readonly now: () => number; @@ -72,6 +73,30 @@ export class InputBridge extends EventEmitter { if (!this.child && !this.restartTimer) this.start(); } + /** + * One question to the running helper: resolves what `read` accepts from its next events, or `fallback` once the + * helper is gone or stays silent past `ms`. `read` returning null means "not my event" and keeps the question open. + */ + ask(cmd: HelperCommand, read: (ev: HelperEvent) => T | null, fallback: T, ms = 1500): Promise { + if (this.current !== 'running') return Promise.resolve(fallback); + return new Promise((resolve) => { + const question = (ev: HelperEvent): boolean => { + const answer = read(ev); + if (answer === null) return false; + clearTimeout(timer); + resolve(answer); + return true; // the pass that saw it drops the question + }; + const timer = setTimeout(() => { + const i = this.questions.indexOf(question); + if (i >= 0) this.questions.splice(i, 1); + resolve(fallback); + }, ms); + this.questions.push(question); + this.write(cmd); + }); + } + /** Always settles: on the child's 'exit' or 'error', or after a 1 s fallback that kills it. */ stop(): Promise { this.stopping = true; @@ -107,6 +132,7 @@ export class InputBridge extends EventEmitter { this.emit('log', `unparsed helper output: ${line}`); return; } + if (this.questions.length > 0) this.questions = this.questions.filter((q) => !q(ev)); if (ev.type === 'ready') { this.setStatus('running'); if (this.lastConfig) this.write(this.lastConfig); diff --git a/src/main/settingsIpc.ts b/src/main/settingsIpc.ts index afd51d70..bf377b1b 100644 --- a/src/main/settingsIpc.ts +++ b/src/main/settingsIpc.ts @@ -3,7 +3,8 @@ import { isCustomRef, isEmoteFx, isEmoteRefShape, type CustomEmote, type EmoteRe import type { Strings } from '../shared/i18n'; import { SETTINGS_CH, TEAMMATE_HISTORY_OPS, type About, type AppStatus, type CheckAgentResult, type CheckKeyResult, - type SecretResult, type SetSettingsResult, type TeammateHistoryOp, type TeammateStatus, type UsageMonth, + type RunningApp, type SecretResult, type SetSettingsResult, type TeammateHistoryOp, type TeammateStatus, + type UsageMonth, } from '../shared/ipc'; import { hotkeyLabel, hotkeyToAccelerator, type Hotkey } from '../shared/keys'; import { pingById, type PingId } from '../shared/pings'; @@ -35,6 +36,10 @@ export interface SettingsIpcDeps { about(): About; retryHelper(): void; setCapturing(on: boolean): void; + /** Hides the settings window for three seconds, then names the app that stayed in front; the capture button asks. */ + captureForegroundApp(): Promise; + /** The apps the user can switch to, with icons; the app-exception list's dropdown asks. */ + appList(): Promise; openAccessibility(): void; /** Strings in the app's current language. */ text(): Strings; @@ -187,6 +192,8 @@ export function registerSettingsIpc(d: SettingsIpcDeps): void { }); ipcMain.handle(SETTINGS_CH.retryHelper, () => d.retryHelper()); ipcMain.handle(SETTINGS_CH.capture, (_e, on: unknown) => d.setCapturing(on === true)); + ipcMain.handle(SETTINGS_CH.captureApp, () => d.captureForegroundApp()); + ipcMain.handle(SETTINGS_CH.appList, () => d.appList()); ipcMain.handle(SETTINGS_CH.openAccessibility, () => d.openAccessibility()); ipcMain.handle(SETTINGS_CH.roomGet, () => d.room.state()); ipcMain.handle(SETTINGS_CH.roomCreate, () => d.room.create()); diff --git a/src/preload/settings.ts b/src/preload/settings.ts index b7b0588b..29972a7e 100644 --- a/src/preload/settings.ts +++ b/src/preload/settings.ts @@ -28,6 +28,8 @@ const api: SettingsApi = { openProjectPage: () => ipcRenderer.invoke(SETTINGS_CH.openProject), retryHelper: () => ipcRenderer.invoke(SETTINGS_CH.retryHelper), setCapturing: (on) => ipcRenderer.invoke(SETTINGS_CH.capture, on), + captureForegroundApp: () => ipcRenderer.invoke(SETTINGS_CH.captureApp), + getAppList: () => ipcRenderer.invoke(SETTINGS_CH.appList), openAccessibility: () => ipcRenderer.invoke(SETTINGS_CH.openAccessibility), getRoom: () => ipcRenderer.invoke(SETTINGS_CH.roomGet), onRoom: (cb) => subscribe(SETTINGS_CH.roomChanged, cb), diff --git a/src/renderer/settings/App.tsx b/src/renderer/settings/App.tsx index 90d2e7f9..7864647a 100644 --- a/src/renderer/settings/App.tsx +++ b/src/renderer/settings/App.tsx @@ -21,6 +21,7 @@ import { KeyCapture } from './components/KeyCapture'; import { PermissionCard } from './components/PermissionCard'; import { RoomSection } from './components/RoomSection'; import { SettingRow, SliderRow, SwitchRow } from './components/rows'; +import { SkipApps } from './components/SkipApps'; import { StatusCard } from './components/StatusCard'; import { TeammateSection } from './components/TeammateSection'; import { TriggerPicker } from './components/TriggerPicker'; @@ -220,6 +221,7 @@ function SettingsPage({ settings: s, status, update }: { settings: Settings; sta description={t.gameCheckDesc} checked={s.gameCheck} onChange={(v) => void update({ gameCheck: v })} dim={off} /> )} +
{t.navToggle}
} title={t.toggleShortcut} description={t.toggleShortcutDesc}> diff --git a/src/renderer/settings/components/SkipApps.tsx b/src/renderer/settings/components/SkipApps.tsx new file mode 100644 index 00000000..168e53fe --- /dev/null +++ b/src/renderer/settings/components/SkipApps.tsx @@ -0,0 +1,100 @@ +import { Button, Combobox, Option } from '@fluentui/react-components'; +import { Add24Regular, Camera24Regular, Dismiss12Regular, FilterDismiss24Regular } from '@fluentui/react-icons'; +import { useState } from 'react'; +import type { RunningApp } from '../../../shared/ipc'; +import { appNameStem, MAX_SKIP_APPS, resolveSkipApp } from '../../../shared/settings'; +import { api } from '../api'; +import { useText } from '../text'; +import type { Update } from '../useAppState'; + +/** Process names that are lolPing itself: never offered, and a capture that never left the app adds nothing. */ +const isOwnApp = (name: string): boolean => { + const stem = appNameStem(name).toLowerCase(); + return stem === 'lolping' || stem === 'electron'; +}; + +/** + * The app-exception list: in these apps lolPing stands down entirely, so their own shortcuts (Photoshop's + * Option + click, a Tab that toggles panels) reach them untouched. The box picks from the apps running right + * now, with their icons where the platform has one, and still takes a typed name. + */ +export function SkipApps(props: { apps: string[]; dim: boolean; update: Update }) { + const t = useText(); + const [name, setName] = useState(''); + const [running, setRunning] = useState([]); + const [note, setNote] = useState(null); + + const refresh = (): void => { + void api.getAppList().then((apps) => setRunning(apps.filter((a) => !isOwnApp(a.name)))); + }; + + const add = (raw: string): void => { + setNote(null); + setName(''); + // A typed name may be the words the system shows for an app (an IME offers those); store the process name then. + const stem = resolveSkipApp(raw, running); + if (stem === '' || props.apps.includes(stem) || props.apps.length >= MAX_SKIP_APPS) return; + void props.update({ skipApps: [...props.apps, stem] }); + }; + + const remove = (app: string): void => { + setNote(null); + void props.update({ skipApps: props.apps.filter((a) => a !== app) }); + }; + + // The window hides for the wait, so the app the user wants is simply the one left in front. + const capture = async (): Promise => { + setNote(null); + const front = await api.captureForegroundApp(); + if (front !== null && !isOwnApp(front)) add(front); + else setNote(front === null ? t.skipAppsCaptureNone : t.skipAppsCaptureSelf); + }; + + return ( + <> +
+
+ +
+ {t.skipApps} + {t.skipAppsDesc} +
+
+
+ setName(e.target.value)} + onOpenChange={(_, d) => { if (d.open) refresh(); }} + onKeyDown={(e) => { if (e.key === 'Enter') add(name); }} + onOptionSelect={(_, d) => { if (typeof d.optionValue === 'string') add(d.optionValue); }}> + {running.map((a) => { + const shown = a.desc && a.desc !== a.name ? a.desc : a.name; + return ( + + ); + })} + + + +
+
+ {props.apps.length === 0 ? {t.skipAppsEmpty} : props.apps.map((a) => ( + + {a} + + + ))} +
+
+ {note ?
{note}
: null} + + ); +} diff --git a/src/renderer/settings/settings.css b/src/renderer/settings/settings.css index 510baa90..4f9ab083 100644 --- a/src/renderer/settings/settings.css +++ b/src/renderer/settings/settings.css @@ -254,3 +254,17 @@ main.content > *, .member, .mateRule, .mateStack > .mateRow { animation: lpRise @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 1ms !important; animation-delay: 0s !important; animation-iteration-count: 1 !important; transition-duration: 1ms !important; } } +/* App exceptions: a block card like the wheel editor; the picker and the chips sit under the heading, at the text's indent. */ +.card.skipApps { display: block; padding: 16px 20px 14px; } +.card.skipApps:hover { background: var(--card); } +.skipHead { display: flex; align-items: flex-start; gap: 16px; } +.skipHead .lbl { flex: 1; } +.skipPick { display: flex; align-items: center; gap: 8px; margin: 14px 0 0 40px; } +.skipList { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 0 40px; min-height: 22px; } +.skipOpt { display: flex; align-items: center; gap: 8px; } +.skipOptIco { display: inline-flex; width: 16px; height: 16px; } +.skipOptIco img { width: 16px; height: 16px; } +.skipDot { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: var(--colorNeutralBackground3); font-size: 10px; line-height: 16px; } +.skipChip { display: inline-flex; align-items: center; gap: 2px; padding: 2px 4px 2px 9px; border-radius: 4px; background: var(--colorNeutralBackground3); font-size: 12px; line-height: 16px; } +.skipChip button { display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .55; } +.skipChip button:hover, .skipChip button:focus-visible { opacity: 1; background: var(--colorNeutralBackground3Hover); } diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts index 51a8902f..855d08d7 100644 --- a/src/shared/i18n.ts +++ b/src/shared/i18n.ts @@ -472,6 +472,14 @@ const en = { `Places the ping in the centre of the wheel. When off, ${trigger} + click passes straight through to the app underneath`, gameCheck: 'Pause in fullscreen apps', gameCheckDesc: 'Games and other fullscreen apps keep every click: trigger + click passes through there, and gestures resume on the desktop', + skipApps: 'App exceptions', + skipAppsDesc: 'In these apps the ping and emote trigger keys - custom ones included - take no effect, and the app’s shortcuts work as usual.', + skipAppsPlaceholder: 'Process name, e.g. Photoshop', + skipAppsAdd: 'Add', + skipAppsCapture: 'Capture front app', + skipAppsCaptureSelf: 'lolPing itself was in front: try again from the app you want', + skipAppsCaptureNone: 'Could not read the front app. Is the helper running?', + skipAppsEmpty: 'No apps: lolPing gestures everywhere', toggleShortcut: 'Enable / disable shortcut', toggleShortcutDesc: 'Works anywhere, even while pinging is off', startEnabled: 'Start enabled', @@ -885,6 +893,14 @@ const zh: Strings = { clickPingDesc: (trigger) => `发送轮盘中心的信号。关闭时,${trigger} + 单击会直接传给下方的程序`, gameCheck: '全屏程序中暂停', gameCheckDesc: '游戏等全屏程序独占点击:触发键 + 单击原样交给程序,回到桌面后手势恢复正常', + skipApps: '程序例外', + skipAppsDesc: '在这些程序里,信号和表情的触发键(自定义的也算)不会生效,程序自己的快捷键一切照常', + skipAppsPlaceholder: '进程名,如 Photoshop', + skipAppsAdd: '添加', + skipAppsCapture: '捕获前台程序', + skipAppsCaptureSelf: '刚才在前台的是 lolPing:请在要添加的程序里再试一次', + skipAppsCaptureNone: '读取前台程序失败,请确认 helper 正在运行', + skipAppsEmpty: '还没有添加程序:手势在所有程序中生效', toggleShortcut: '开关快捷键', toggleShortcutDesc: '在任何地方都可用,即使信号已关闭', startEnabled: '启动时开启', diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 8a807eee..757e5fa2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -252,6 +252,18 @@ export const TEAMMATE_HISTORY_OPS: readonly TeammateHistoryOp[] = ['open', 'paus // with the overlay preload (a sandboxed preload can only require('electron'), never a sibling file). export { SETTINGS_CH } from './settingsChannels'; +/** One app the exception dropdown offers: its process name, image path and icon. */ +export interface RunningApp { + /** The process image's file name, "Photoshop.exe"; what the exception list stores. */ + name: string; + /** The words the system shows for it (the file's description); what a user types, so adding can match it. */ + desc: string; + /** The image's full path; only to fetch the icon. */ + path: string; + /** The icon as a PNG data URL, null where there is none worth showing. */ + icon: string | null; +} + /** window.settingsApi in the settings renderer. */ export interface SettingsApi { readonly platform: Platform; @@ -277,6 +289,11 @@ export interface SettingsApi { openProjectPage(): Promise; retryHelper(): Promise; setCapturing(on: boolean): Promise; + /** Hides the settings window for three seconds, then names the app that stayed in front, or null when the helper + * can't tell. Feeds the app-exception list's capture button. */ + captureForegroundApp(): Promise; + /** The apps the user can switch to right now, with icons, [] when the helper can't tell. Feeds the exception list's dropdown. */ + getAppList(): Promise; /** macOS: asks for Accessibility access and opens that page of System Settings. */ openAccessibility(): Promise; getRoom(): Promise; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 91d1c008..bd014141 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -8,8 +8,10 @@ export type WheelKind = 'ping' | 'emote' | 'ask'; /** * The helper's `ready` version this build expects. 5: the ask key, `"wheel":"ask"` and `triggerUp`. * 6 adds `askWords`, `scroll`, the `askDoubleTap` config field and the `staleWatch` command. + * 7 adds the app-exception list: the `skipApps` config field, the `foregroundApp` and `appList` events + * and the `getForegroundApp` and `getAppList` commands. */ -export const HELPER_VERSION = 6; +export const HELPER_VERSION = 7; /** * Messages the hook helper writes to stdout, one JSON object per line. @@ -28,6 +30,8 @@ export type HelperEvent = | { type: 'askWords'; x: number; y: number } /** A scroll while the stale watch is on; the helper sends at most one per 100 ms. */ | { type: 'scroll'; x: number; y: number } + | { type: 'foregroundApp'; name: string } + | { type: 'appList'; apps: { name: string; path: string; desc: string }[] } | { type: 'error'; message: string; code?: string } | { type: 'sim'; swallow: boolean; inject: string }; @@ -67,6 +71,19 @@ export function parseHelperLine(line: string): HelperEvent | null { case 'askWords': case 'scroll': return isInt(o.x) && isInt(o.y) ? { type: o.type, x: o.x, y: o.y } : null; + case 'foregroundApp': + return typeof o.name === 'string' ? { type: 'foregroundApp', name: o.name } : null; + case 'appList': { + if (!Array.isArray(o.apps)) return null; + const apps: { name: string; path: string; desc: string }[] = []; + for (const a of o.apps.slice(0, 64)) { + const e = typeof a === 'object' && a !== null && !Array.isArray(a) ? a as Record : null; + if (e && typeof e.name === 'string' && typeof e.path === 'string') { + apps.push({ name: e.name, path: e.path, desc: typeof e.desc === 'string' ? e.desc : '' }); + } + } + return { type: 'appList', apps }; + } case 'error': if (typeof o.message !== 'string') return null; return typeof o.code === 'string' ? { type: 'error', message: o.message, code: o.code } : { type: 'error', message: o.message }; @@ -94,6 +111,8 @@ export type HelperCommand = clickPing: boolean; /** Fullscreen apps keep every click: no wheel gestures while one is in front. */ gameCheck: boolean; + /** Comma-separated process names where lolPing stands down entirely. Empty: every app gets gestures. */ + skipApps: string; /** 'off': no emote key, so no emote gestures. */ emoteTrigger: HelperTrigger | 'off'; emoteTriggerVk: number; @@ -116,6 +135,8 @@ export type HelperCommand = | { type: 'suspend'; on: boolean } /** Report scrolls. Its own command, not a config field: a config cancels a gesture the user may be in. */ | { type: 'staleWatch'; on: boolean } + | { type: 'getForegroundApp' } + | { type: 'getAppList' } | { type: 'shutdown' }; /** @@ -141,6 +162,7 @@ export function configCommand( triggerVk: typeof t === 'object' ? t.vk : 0, clickPing: s.clickPing || sameWords, gameCheck: s.gameCheck, + skipApps: s.skipApps.join(','), emoteTrigger: typeof e === 'object' ? 'vk' : e, emoteTriggerVk: typeof e === 'object' ? e.vk : 0, emoteClick: s.emoteClick && e !== 'off', diff --git a/src/shared/settings.ts b/src/shared/settings.ts index d7e54f87..cb804723 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -40,6 +40,8 @@ export interface Settings { clickPingId: PingId; /** Fullscreen apps (games) keep every click: trigger + click passes through while one is front. */ gameCheck: boolean; + /** Apps where lolPing stands down entirely, gestures and hotkeys alike: process names without .exe. */ + skipApps: string[]; /** The displayed SVG size, shared by the ping and emote wheels. */ wheelSizePx: number; /** The wheel's slices, top (N) then clockwise. */ @@ -109,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = { dragThresholdPx: 8, clickPing: false, gameCheck: true, + skipApps: [], clickPingId: DEFAULT_CLICK_PING, wheelSizePx: WHEEL.size, wheel: [...DEFAULT_WHEEL], @@ -160,6 +163,26 @@ type NumKey = keyof typeof LIMITS; type BoolKey = 'enabledOnStart' | 'clickPing' | 'gameCheck' | 'keepLastPing' | 'muted' | 'tickSound' | 'launchAtStartup' | 'allowInternet' | 'rejoinRoom' | 'roomMuted' | 'emoteClick' | 'emoteSound' | 'friendEmotes' | 'autoUpdateCheck'; +/** At most MAX_SKIP_APPS entries: the list exists for a handful of apps. */ +export const MAX_SKIP_APPS = 32; + +/** One process name as the exception list stores it: trimmed, without a trailing ".exe". Case is kept for display; + * every match, here and in the helper, ignores it. */ +export const appNameStem = (name: string): string => { + const trimmed = name.trim(); + return trimmed.toLowerCase().endsWith('.exe') ? trimmed.slice(0, -4) : trimmed; +}; + +/** + * What an exception entry should store for `input`: a running app's process name when the user typed the words the + * system shows for it (an IME offers "网易UU远程", the process is GameViewer.exe), else the typed name as a stem. + */ +export const resolveSkipApp = (input: string, running: readonly { name: string; desc?: string }[]): string => { + const typed = input.trim().toLowerCase(); + const match = running.find((a) => typeof a.desc === 'string' && a.desc.trim().toLowerCase() === typed); + return match ? appNameStem(match.name) : appNameStem(input); +}; + const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); const isVk = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 254; @@ -299,6 +322,20 @@ function roomCode(raw: unknown): string | null { return parsed.ok ? parsed.code : null; } +/** Process names where lolPing stands down: trimmed, no .exe, no duplicate stems; the first MAX_SKIP_APPS. */ +function skipApps(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const out: string[] = []; + for (const e of raw) { + if (out.length >= MAX_SKIP_APPS) break; + if (typeof e !== 'string') continue; + const stem = appNameStem(e); + if (stem === '' || out.some((kept) => kept.toLowerCase() === stem.toLowerCase())) continue; + out.push(stem); + } + return out; +} + /** Valid entries only, rebuilt field by field: well-formed unique ids, boolean flags, a safe name; the first MAX_CUSTOM_EMOTES. */ function customEmotes(raw: unknown): CustomEmote[] { if (!Array.isArray(raw)) return []; @@ -341,6 +378,7 @@ export function normalizeSettings(raw: unknown, platform: Platform = 'win', iden dragThresholdPx: num(r.dragThresholdPx, 'dragThresholdPx'), clickPing: bool(r.clickPing, 'clickPing'), gameCheck: bool(r.gameCheck, 'gameCheck'), + skipApps: skipApps(r.skipApps), wheelSizePx: num(r.wheelSizePx, 'wheelSizePx'), clickPingId: isPingId(r.clickPingId) ? r.clickPingId : DEFAULT_CLICK_PING, wheel: isValidWheel(r.wheel) ? [...r.wheel] : [...DEFAULT_WHEEL], diff --git a/src/shared/settingsChannels.ts b/src/shared/settingsChannels.ts index f54d88b9..246ab76b 100644 --- a/src/shared/settingsChannels.ts +++ b/src/shared/settingsChannels.ts @@ -17,6 +17,8 @@ export const SETTINGS_CH = { openProject: 'app:openProjectPage', retryHelper: 'app:retryHelper', capture: 'app:capture', + captureApp: 'app:captureApp', + appList: 'app:appList', openAccessibility: 'app:openAccessibility', roomGet: 'room:get', roomChanged: 'room:state', diff --git a/tests/helper/protocol.test.ts b/tests/helper/protocol.test.ts index b5ed981a..c9107832 100644 --- a/tests/helper/protocol.test.ts +++ b/tests/helper/protocol.test.ts @@ -34,7 +34,7 @@ describe('hook-helper --simulate', () => { it('announces itself first', async () => { const events = await simulate([]); expect(events[0]).toEqual({ type: 'ready', version: HELPER_VERSION }); - expect(HELPER_VERSION).toBe(6); + expect(HELPER_VERSION).toBe(7); }); it('opens and releases the wheel on Alt+drag', async () => { diff --git a/tests/main/appIcons.test.ts b/tests/main/appIcons.test.ts new file mode 100644 index 00000000..a3975180 --- /dev/null +++ b/tests/main/appIcons.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { AppIcons } from '../../src/main/appIcons'; + +describe('AppIcons', () => { + it('fetches one icon per path and serves the rest from the cache', async () => { + let reads = 0; + const icons = new AppIcons(async (path) => { + ++reads; + return `icon:${path}`; + }); + expect(await icons.icon('C:\\a.exe')).toBe('icon:C:\\a.exe'); + expect(await icons.icon('C:\\a.exe')).toBe('icon:C:\\a.exe'); + expect(await icons.icon('C:\\b.exe')).toBe('icon:C:\\b.exe'); + expect(reads).toBe(2); + }); + + it('reads the reference once and hides icons that only copy it', async () => { + const reads: string[] = []; + const icons = new AppIcons(async (path) => { + reads.push(path); + return path === 'ref.exe' || path === 'zcode.exe' ? 'default' : 'own'; + }, 'ref.exe'); + expect(await icons.icon('zcode.exe')).toBeNull(); // the default in disguise + expect(await icons.icon('own.exe')).toBe('own'); + expect(reads.filter((p) => p === 'ref.exe')).toHaveLength(1); + }); + + it('shows everything when there is no reference file', async () => { + const icons = new AppIcons(async (path) => (path === 'C:\\b.exe' ? null : 'own')); + expect(await icons.icon('C:\\a.exe')).toBe('own'); + expect(await icons.icon('C:\\b.exe')).toBeNull(); + expect(await icons.icon('')).toBeNull(); + }); +}); diff --git a/tests/main/fixtures/fake-helper.mjs b/tests/main/fixtures/fake-helper.mjs index 970e6184..4b90c0f0 100644 --- a/tests/main/fixtures/fake-helper.mjs +++ b/tests/main/fixtures/fake-helper.mjs @@ -16,9 +16,17 @@ if (mode === 'noaccess') { const cmd = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1); if (cmd.type === 'shutdown') process.exit(0); - // Echo back what we received so the test can observe it. - process.stdout.write(`${JSON.stringify({ type: 'error', message: `got:${cmd.type}` })}\n`); + process.stdout.write(`${JSON.stringify(reply(cmd))}\n`); } }); process.stdin.on('end', () => process.exit(0)); } + +// 'answer' fills in the helper's app queries; every other command is echoed back so the test can observe it. +function reply(cmd) { + if (mode === 'answer' && cmd.type === 'getForegroundApp') return { type: 'foregroundApp', name: 'Photoshop.exe' }; + if (mode === 'answer' && cmd.type === 'getAppList') { + return { type: 'appList', apps: [{ name: 'GameViewer.exe', desc: '网易UU远程', path: 'C:\\NetEase\\GameViewer.exe', hasIcon: true }] }; + } + return { type: 'error', message: `got:${cmd.type}` }; +} diff --git a/tests/main/inputBridge.test.ts b/tests/main/inputBridge.test.ts index 4b8ab7cc..dd311262 100644 --- a/tests/main/inputBridge.test.ts +++ b/tests/main/inputBridge.test.ts @@ -45,7 +45,7 @@ describe('InputBridge', () => { it('re-sends the last config whenever the helper becomes ready', async () => { const b = bridge('echo'); b.send({ - type: 'config', trigger: 'alt', triggerVk: 0, clickPing: false, gameCheck: true, emoteTrigger: 'ctrl', emoteTriggerVk: 0, emoteClick: false, + type: 'config', trigger: 'alt', triggerVk: 0, clickPing: false, gameCheck: true, skipApps: '', emoteTrigger: 'ctrl', emoteTriggerVk: 0, emoteClick: false, askTrigger: 'off', askTriggerVk: 0, askClick: false, askDoubleTap: false, dragThresholdPx: 8, toggleMods: 3, toggleVk: 80, textMods: 3, textVk: 84, panelVk: 0, enabled: true, }); @@ -54,6 +54,26 @@ describe('InputBridge', () => { await echoed; }); + it('asks the helper and resolves its answer', async () => { + const b = bridge('answer'); + const read = (e: HelperEvent) => (e.type === 'foregroundApp' ? e.name : null); + expect(await b.ask({ type: 'getForegroundApp' }, read, 'none')).toBe('none'); // not running yet + const running = waitFor(b, 'status', (s) => s === 'running'); + b.start(); + await running; + expect(await b.ask({ type: 'getForegroundApp' }, read, 'none')).toBe('Photoshop.exe'); + }); + + it('settles the fallback when nothing answers in time', async () => { + const b = bridge('answer'); + const running = waitFor(b, 'status', (s) => s === 'running'); + b.start(); + await running; + // setEnabled is echoed as an error event, which the question does not read: it runs out of time instead. + const read = (e: HelperEvent) => (e.type === 'appList' ? e.apps[0]?.name ?? 'none' : null); + expect(await b.ask({ type: 'setEnabled', enabled: true }, read, 'none', 30)).toBe('none'); + }); + it('restarts a crashing helper and gives up after three crashes', async () => { const b = bridge('crash'); const failed = waitFor(b, 'status', (s) => s === 'failed'); diff --git a/tests/shared/protocol.test.ts b/tests/shared/protocol.test.ts index 7a7c856a..33fdd112 100644 --- a/tests/shared/protocol.test.ts +++ b/tests/shared/protocol.test.ts @@ -16,6 +16,17 @@ describe('parseHelperLine', () => { expect(parseHelperLine('{"type":"textRequested"}')).toEqual({ type: 'textRequested' }); expect(parseHelperLine('{"type":"panel","show":true}')).toEqual({ type: 'panel', show: true }); expect(parseHelperLine('{"type":"panel","show":false}')).toEqual({ type: 'panel', show: false }); + expect(parseHelperLine('{"type":"foregroundApp","name":"Photoshop.exe"}')).toEqual({ type: 'foregroundApp', name: 'Photoshop.exe' }); + expect(parseHelperLine('{"type":"appList","apps":[{"name":"Photoshop.exe","desc":"Adobe Photoshop","path":"C:\\\\Adobe\\\\Photoshop.exe"},{"name":"chrome","path":"/opt/chrome"}]}')) + .toEqual({ + type: 'appList', + apps: [ + { name: 'Photoshop.exe', desc: 'Adobe Photoshop', path: 'C:\\Adobe\\Photoshop.exe' }, + { name: 'chrome', desc: '', path: '/opt/chrome' }, + ], + }); + expect(parseHelperLine('{"type":"appList","apps":[{"name":"no path"},{"path":"C:\\\\b.exe"},"text",{"name":"","path":"","desc":7}]}')) + .toEqual({ type: 'appList', apps: [{ name: '', path: '', desc: '' }] }); expect(parseHelperLine('{"type":"error","message":"boom"}')).toEqual({ type: 'error', message: 'boom' }); expect(parseHelperLine('{"type":"error","code":"noAccess","message":"x"}')).toEqual({ type: 'error', code: 'noAccess', message: 'x' }); expect(parseHelperLine('{"type":"sim","swallow":true,"inject":"down:left"}')).toEqual({ type: 'sim', swallow: true, inject: 'down:left' }); @@ -54,7 +65,8 @@ describe('parseHelperLine', () => { it('rejects malformed lines', () => { for (const line of ['', 'nope', '42', 'null', '{"type":"wheelOpen","x":1}', '{"type":"click","x":1.5,"y":2}', - '{"type":"toggled"}', '{"type":"panel"}', '{"type":"panel","show":"yes"}', '{"type":"mystery"}', '{"x":1,"y":2}']) { + '{"type":"toggled"}', '{"type":"panel"}', '{"type":"panel","show":"yes"}', '{"type":"foregroundApp"}', '{"type":"appList"}', + '{"type":"appList","apps":"x"}', '{"type":"mystery"}', '{"x":1,"y":2}']) { expect(parseHelperLine(line), line).toBeNull(); } }); @@ -63,13 +75,18 @@ describe('parseHelperLine', () => { describe('commands', () => { it('builds a flat config command for named triggers', () => { expect(configCommand(DEFAULT_SETTINGS, true)).toEqual({ - type: 'config', trigger: 'alt', triggerVk: 0, clickPing: false, gameCheck: true, + type: 'config', trigger: 'alt', triggerVk: 0, clickPing: false, gameCheck: true, skipApps: '', emoteTrigger: 'ctrl', emoteTriggerVk: 0, emoteClick: false, askTrigger: 'off', askTriggerVk: 0, askClick: false, askDoubleTap: false, dragThresholdPx: 8, toggleMods: 3, toggleVk: 0x50, textMods: 2, textVk: 0, panelVk: 0, enabled: true, }); }); + it('sends the skip list as one comma-joined name', () => { + expect(configCommand({ ...DEFAULT_SETTINGS, skipApps: ['photoshop', 'idea64'] }, true)) + .toMatchObject({ skipApps: 'photoshop,idea64' }); + }); + it('sends textVk 0 outside a room', () => { expect(configCommand(DEFAULT_SETTINGS, true, { text: false, panel: false })).toMatchObject({ textMods: 2, textVk: 0 }); }); diff --git a/tests/shared/settings.test.ts b/tests/shared/settings.test.ts index b8e6c4c4..101cade1 100644 --- a/tests/shared/settings.test.ts +++ b/tests/shared/settings.test.ts @@ -3,9 +3,9 @@ import { DEFAULT_CLICK_EMOTE, DEFAULT_EMOTE_WHEEL } from '../../src/shared/emote import { MOD } from '../../src/shared/keys'; import { formatRoomCode, makeRoomCode } from '../../src/shared/roomCode'; import { - askKeyClashes, askKeyHeld, asksOnPing, DEFAULT_SETTINGS, keyboardTriggers, mergeSettings, normalizeSettings, - overlaySettings, patchClashes, rendererPatch, sameTrigger, separateAskKey, soundLevel, triggerLabel, triggersClash, - type TriggerKey, + askKeyClashes, askKeyHeld, asksOnPing, DEFAULT_SETTINGS, keyboardTriggers, MAX_SKIP_APPS, mergeSettings, + normalizeSettings, overlaySettings, patchClashes, rendererPatch, resolveSkipApp, sameTrigger, separateAskKey, + soundLevel, triggerLabel, triggersClash, type TriggerKey, } from '../../src/shared/settings'; import type { AskTrigger } from '../../src/shared/teammate/settings'; @@ -317,6 +317,26 @@ describe('emote settings', () => { expect(list).toEqual([{ ...entry('a'), sound: true }, entry('b')]); }); + it('skip apps keep their case but match stems without .exe, deduplicated', () => { + expect(normalizeSettings({ skipApps: [' Photoshop.EXE ', 'photoshop', 'IDEA64', '', 'nope.EXE', 7, null] }).skipApps) + .toEqual(['Photoshop', 'IDEA64', 'nope']); + expect(normalizeSettings({ skipApps: 'nope' }).skipApps).toEqual([]); + expect(DEFAULT_SETTINGS.skipApps).toEqual([]); + }); + + it('a typed display name becomes the running app it names', () => { + const running = [{ name: 'GameViewer.exe', desc: '网易UU远程' }, { name: 'ZCode.exe', desc: 'ZCode' }]; + expect(resolveSkipApp(' 网易UU远程 ', running)).toBe('GameViewer'); + expect(resolveSkipApp('zcode.exe', running)).toBe('zcode'); + expect(resolveSkipApp('Notepad', running)).toBe('Notepad'); + }); + + it('skip apps keep the first 32', () => { + const kept = normalizeSettings({ skipApps: Array.from({ length: 40 }, (_, i) => `app${i}`) }).skipApps; + expect(kept).toHaveLength(MAX_SKIP_APPS); + expect(kept[0]).toBe('app0'); + }); + it('custom emotes keep the first 24', () => { const many = Array.from({ length: 30 }, (_, i) => ({ id: `c:${i.toString(16).padStart(32, '0')}`, name: `E${i}`, animated: false })); const kept = normalizeSettings({ customEmotes: many }).customEmotes;