Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + 单击发送的信号。单击信号可预览。**恢复默认**会还原英雄联盟的布局。
Expand Down
10 changes: 9 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
Expand Down
Binary file added docs/media/app-exceptions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion native/hook-helper/build.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions native/hook-helper/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions native/hook-helper/src/commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Command parseCommand(const std::string& line, const Config& current) {
cfg.triggerVk = static_cast<uint32_t>(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<uint32_t>(getInt(m, "emoteTriggerVk", cfg.emoteTriggerVk));
cfg.emoteClick = getBool(m, "emoteClick", cfg.emoteClick);
Expand All @@ -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") {
Expand Down
2 changes: 1 addition & 1 deletion native/hook-helper/src/commands.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions native/hook-helper/src/decision.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "decision.h"

#include <algorithm>
#include <cmath>

namespace lp {
Expand All @@ -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:
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -262,6 +287,17 @@ Result Decision::setStaleWatch(bool on) {

void Decision::setFullscreenProbe(std::function<bool(int x, int y)> probe) { fullscreenProbe_ = std::move(probe); }

void Decision::setForegroundApp(std::function<std::string()> 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;
Expand Down
13 changes: 12 additions & 1 deletion native/hook-helper/src/decision.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <cstdint>
#include <functional>
#include <set>
#include <string>
#include <vector>

namespace lp {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool(int x, int y)> 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<std::string()> 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; }
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -171,6 +180,8 @@ class Decision {
bool enabled_ = true;
bool suspended_ = false;
std::function<bool(int x, int y)> fullscreenProbe_;
std::function<std::string()> foregroundApp_;
std::vector<std::string> skipStems_; // cfg_.skipApps split and normalised, refreshed by setConfig
State state_ = State::Idle;
std::array<bool, 256> keyDown_{};
std::set<uint32_t> swallowKeyUps_;
Expand Down
Loading
Loading