diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..4573cb3a --- /dev/null +++ b/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783389287, + "narHash": "sha256-0xIy4dVLqq47rA+mRy0hXDfjhQd4E5PoIns/RmB7nR4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "0ad6f47ea4fe188f4bc8f0380f93ae8523337c6c", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-qt68": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "nixpkgs-qt68": "nixpkgs-qt68" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..1a1f010f --- /dev/null +++ b/flake.nix @@ -0,0 +1,80 @@ +{ + description = "Development environment for Mousemaster - Linux port"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; + # nixos-24.11 ships Qt 6.8.x, matching QtJambi 6.8.2. + # QtJambi does a hard version check and refuses Qt 6.9+. + nixpkgs-qt68.url = "github:nixos/nixpkgs/nixos-24.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, nixpkgs-qt68, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + pkgs68 = import nixpkgs-qt68 { inherit system; }; + in { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + # Java development + jdk21 + maven + + # X11 libs loaded by JNA at runtime + libx11 + libxrandr + libxtst # XTest extension - for key/mouse injection + libxi + libxext + libxrender + libxfixes + + # Qt 6.8.x runtime — must match QtJambi 6.8.2 (from nixos-24.11) + pkgs68.qt6.qtbase + + # System libs that Qt 6 and the bundled QtJambi .so files will dlopen + libxcb + xcbutil + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + xcbutilwm + libxkbcommon + fontconfig + freetype + mesa # provides libGL / libEGL + + wayland # libwayland-client.so, for the Wayland virtual-pointer JNA bindings + ]; + + shellHook = '' + export JAVA_HOME="${pkgs.jdk21}" + export LD_LIBRARY_PATH="${pkgs68.qt6.qtbase}/lib:${pkgs.lib.makeLibraryPath (with pkgs; [ + libx11 + libxrandr + libxtst + libxi + libxext + libxrender + libxfixes + libxcb + xcbutil + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + xcbutilwm + libxkbcommon + fontconfig + freetype + mesa + wayland + ])}:$LD_LIBRARY_PATH" + export QT_QPA_PLATFORM_PLUGIN_PATH="${pkgs68.qt6.qtbase}/lib/qt-6/plugins/platforms" + echo "Mousemaster dev environment ready" + echo "Java: $(java -version 2>&1 | head -n1)" + echo "Maven: $(mvn -version 2>&1 | head -n1)" + ''; + }; + }); +} diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index 0bb0b844..80ab2f24 100644 --- a/pom.xml +++ b/pom.xml @@ -55,11 +55,6 @@ qtjambi 6.8.2 - - io.qtjambi - qtjambi-native-windows-x64 - 6.8.2 - com.google.code.gson gson @@ -80,6 +75,35 @@ + + windows + + windows + + + + io.qtjambi + qtjambi-native-windows-x64 + 6.8.2 + + + + + linux + + Linux + + + mousemaster.platform.linux.LinuxMain + + + + io.qtjambi + qtjambi-native-linux-x64 + 6.8.2 + + + native @@ -161,7 +185,9 @@ src/main/resources true - **/*.dll + **/*.dll + **/*.so + **/*.so.* diff --git a/src/main/java/mousemaster/ComboWatcher.java b/src/main/java/mousemaster/ComboWatcher.java index e748a063..059f5471 100644 --- a/src/main/java/mousemaster/ComboWatcher.java +++ b/src/main/java/mousemaster/ComboWatcher.java @@ -25,11 +25,14 @@ public class ComboWatcher { private final HintManager hintManager; private final ActiveAppFinder activeAppFinder; private final Clock clock; - private final Set pressedComboPreconditionKeys; private final boolean logRedactKeys; - private final Set unpressedComboPreconditionKeys; + // TODO: this class was modified in commits c85df26/2ad3808 (plus this session, + // uncommitted) to fix Linux key masking. Shared, non-platform code — confirm + // safe/valid here vs. Linux-specific before merge. private final Map> pressedPreconditionKeysByMode; + private final Map> unpressedPreconditionKeysByMode; private Set currentModePressedPreconditionKeys; + private Set currentModeUnpressedPreconditionKeys; private List comboListeners; private List modeListeners; private Mode baseMode; @@ -160,17 +163,12 @@ static Map comboPreparationMinRetainEventCountByMode(ModeMap mode public ComboWatcher(CommandRunner commandRunner, HintManager hintManager, ActiveAppFinder activeAppFinder, Clock clock, - Set unpressedComboPreconditionKeys, - Set pressedComboPreconditionKeys, boolean logRedactKeys, + boolean logRedactKeys, ModeMap modeMap) { this.commandRunner = commandRunner; this.hintManager = hintManager; this.activeAppFinder = activeAppFinder; this.clock = clock; - this.unpressedComboPreconditionKeys = - unpressedComboPreconditionKeys; - this.pressedComboPreconditionKeys = - pressedComboPreconditionKeys; this.logRedactKeys = logRedactKeys; this.comboPreparationRetainDurationByMode = comboPreparationRetainDurationByMode(modeMap); this.comboPreparationMinRetainEventCountByMode = comboPreparationMinRetainEventCountByMode(modeMap); @@ -186,17 +184,24 @@ public ComboWatcher(CommandRunner commandRunner, HintManager hintManager, } this.comboPreparation = ComboPreparation.empty(); Map> preconditionKeysByMode = new HashMap<>(); + Map> unpressedPreconditionKeysByMode = new HashMap<>(); for (Mode mode : modeMap.modes()) { Set keys = new HashSet<>(); + Set unpressedKeys = new HashSet<>(); for (Combo combo : mode.comboMap().commandsByCombo().keySet()) { keys.addAll(combo.precondition() .keyPrecondition() .pressedKeyPrecondition() .allKeys()); + unpressedKeys.addAll(combo.precondition() + .keyPrecondition() + .unpressedKeySet()); } preconditionKeysByMode.put(mode, keys); + unpressedPreconditionKeysByMode.put(mode, unpressedKeys); } this.pressedPreconditionKeysByMode = preconditionKeysByMode; + this.unpressedPreconditionKeysByMode = unpressedPreconditionKeysByMode; } public void setComboListeners(List comboListeners) { @@ -489,9 +494,9 @@ public PressKeyEventProcessingSet keyEvent(KeyEvent event) { } modeJustTimedOut = false; boolean isUnpressedComboPreconditionKey = - unpressedComboPreconditionKeys.contains(event.key()); + currentModeUnpressedPreconditionKeys.contains(event.key()); boolean isPressedComboPreconditionKey = - pressedComboPreconditionKeys.contains(event.key()); + currentModePressedPreconditionKeys.contains(event.key()); boolean isComboPreconditionKey = isUnpressedComboPreconditionKey || isPressedComboPreconditionKey; @@ -601,8 +606,10 @@ public PressKeyEventProcessingSet keyEvent(KeyEvent event) { if (isIgnoredByLeadingWait) processing = PressKeyEventProcessing.ignoredByLeadingWait(leadingWaitEatsEvents); else // isComboPreconditionKey must be true + // Eaten speculatively; regurgitated on release if no combo ends up + // using it (see KeyboardManager's eatenKeys handling). processing = isPressedComboPreconditionKey ? - PressKeyEventProcessing.partOfPressedComboPreconditionOnly() : + PressKeyEventProcessing.partOfPressedComboPreconditionOnly(true) : PressKeyEventProcessing.partOfUnpressedComboPreconditionOnly(); processingSet = new PressKeyEventProcessingSet( new HashMap<>(Map.of(PressKeyEventProcessingSet.dummyCombo, processing)), @@ -852,8 +859,8 @@ else if (match.lastEventAbsorbedByWait() comboPreparationBreaker); // This processingByCombo does not need to have entries about // non-combo sequences (i.e. combo preconditions). - // That is because preconditions are managed by the caller (keyEvent) - // which checks across all modes, not just the current one. (isComboPreconditionKey) + // That is because preconditions are managed by the caller (keyEvent), + // scoped to the current mode's precondition keys. (isComboPreconditionKey) processingByCombo.put(combo, processing); matchByCombo.put(combo, match); } @@ -1405,6 +1412,8 @@ public void modeChanged(Mode newMode) { activeMutations.clear(); currentModePressedPreconditionKeys = pressedPreconditionKeysByMode.getOrDefault(newMode, Set.of()); + currentModeUnpressedPreconditionKeys = + unpressedPreconditionKeysByMode.getOrDefault(newMode, Set.of()); computePreconditionOnlyByPropertyPath(); if (!refreshPreconditionOnlyMutations()) notifyMutatedMode(); diff --git a/src/main/java/mousemaster/GridManager.java b/src/main/java/mousemaster/GridManager.java index f5bcf9d3..8cb6a7af 100644 --- a/src/main/java/mousemaster/GridManager.java +++ b/src/main/java/mousemaster/GridManager.java @@ -107,10 +107,10 @@ private void moveGrid(int deltaX, int deltaY) { // We want the grid center in screen. Screen activeScreen = screenManager.activeScreen(); gridCenterX = Math.max(activeScreen.rectangle().x(), Math.min( - activeScreen.rectangle().x() + activeScreen.rectangle().width(), + activeScreen.rectangle().x() + activeScreen.rectangle().width() - 1, gridCenterX)); gridCenterY = Math.max(activeScreen.rectangle().y(), Math.min( - activeScreen.rectangle().y() + activeScreen.rectangle().height(), + activeScreen.rectangle().y() + activeScreen.rectangle().height() - 1, gridCenterY)); x = gridCenterX - grid.width() / 2; y = gridCenterY - grid.height() / 2; @@ -188,10 +188,10 @@ private void snap(boolean horizontal, boolean forward) { if (screenManager.screenContaining(x, y) == null) { Screen activeScreen = screenManager.activeScreen(); x = Math.max(activeScreen.rectangle().x(), Math.min( - activeScreen.rectangle().x() + activeScreen.rectangle().width(), + activeScreen.rectangle().x() + activeScreen.rectangle().width() - 1, x)); y = Math.max(activeScreen.rectangle().y(), Math.min( - activeScreen.rectangle().y() + activeScreen.rectangle().height(), + activeScreen.rectangle().y() + activeScreen.rectangle().height() - 1, y)); } moveMouseTo(x, y); @@ -202,7 +202,7 @@ private int mouseColumnX(int mouseColumn, int cellWidth) { if (mouseColumn <= 0) x = grid.x(); else if (mouseColumn >= grid.columnCount()) - x = grid.x() + grid.width(); + x = grid.x() + grid.width() - 1; else x = grid.x() + mouseColumn * cellWidth; return x; @@ -213,7 +213,7 @@ private int mouseRowY(int mouseRow, int cellHeight) { if (mouseRow <= 0) y = grid.y(); else if (mouseRow >= grid.rowCount()) - y = grid.y() + grid.height(); + y = grid.y() + grid.height() - 1; else y = grid.y() + mouseRow * cellHeight; return y; diff --git a/src/main/java/mousemaster/KeyboardManager.java b/src/main/java/mousemaster/KeyboardManager.java index 1d061f86..3ecea279 100644 --- a/src/main/java/mousemaster/KeyboardManager.java +++ b/src/main/java/mousemaster/KeyboardManager.java @@ -173,9 +173,16 @@ else if (processingSet.isPartOfComboSequence()) { } boolean mustBeEaten = processingSet.mustBeEaten() || macroPlayer.isKeyPressedByMacro(key); + // Forward before keyPressedNotEaten so regurgitated keys aren't delivered + // out of order (keyPressedNotEaten writes to uinput synchronously on Linux). + // TODO: shared code. Consider how this affects Windows. Is this the correct + // location for this solution? Is this the correct solution? + for (Regurgitate regurgitate : regurgitates) { + keyRegurgitator.regurgitate(regurgitate, !regurgitate.alsoRelease()); + } if (!mustBeEaten) macroPlayer.keyPressedNotEaten(key); - return eatAndRegurgitates(mustBeEaten, regurgitates); + return eatAndRegurgitates(mustBeEaten, List.of()); } else { // Key release. if (processingSet != null) { @@ -263,7 +270,12 @@ else if (processingSet.isPartOfComboSequence()) { eatenKeys.put(key, new Eat(true, existingEat.processingSet())); } } - if (!mustBeEaten) + // Avoid double-delivering the release on platforms that re-inject + // not-eaten keys (Linux uinput): don't also report "not eaten" if + // it's already queued as a regurgitate. + boolean releasedByRegurgitation = regurgitates.stream() + .anyMatch(r -> r.key().equals(key) && r.alsoRelease()); + if (!mustBeEaten && !releasedByRegurgitation) macroPlayer.keyReleasedNotEaten(key); return eatAndRegurgitates(mustBeEaten, regurgitates); } @@ -281,6 +293,9 @@ else if (processingSet.isPartOfComboSequence()) { } } + // TODO: this method, isRetainedByCombos, addRegurgitate, and handleDeadEatingCombos + // below were modified in commits c85df26/2ad3808 to fix Linux key masking. Shared, + // non-platform code — confirm this is safe/valid here vs. Linux-specific before merge. private boolean markOtherKeysOfTheseCombosAsCompleted(List completedCombos, boolean forceIsComboPreparationBreaker) { boolean completedCombosHavePressedKeys = false; @@ -325,6 +340,28 @@ private boolean markOtherKeysOfTheseCombosAsCompleted(List comple processing.mustBeEaten(), true, processing.isComboPreparationBreaker() || forceIsComboPreparationBreaker)); } + // Precondition keys are tracked under dummyCombo, not combo, so they need + // marking separately to avoid being regurgitated once their combo fires. + for (Key key : combo.precondition().keyPrecondition().pressedKeyPrecondition().allKeys()) { + PressKeyEventProcessingSet processingSet = currentlyPressedKeys.get(key); + if (processingSet == null) { + Eat eat = eatenKeys.get(key); + if (eat != null) + processingSet = eat.processingSet(); + } + if (processingSet == null) + continue; + Map processingByCombo = + processingSet.processingByCombo(); + for (Map.Entry entry : + Set.copyOf(processingByCombo.entrySet())) { + if (entry.getValue().isPartOfPressedComboPreconditionOnly()) + processingByCombo.put(entry.getKey(), + PressKeyEventProcessing.partOfComboSequence( + entry.getValue().mustBeEaten(), true, + forceIsComboPreparationBreaker)); + } + } } return completedCombosHavePressedKeys; } @@ -350,18 +387,12 @@ private List buildRegurgitates(Key filterKey, Key releasingKey, boolean alsoRelease; if (eat.released()) { alsoRelease = true; - if (!retainCombos.isEmpty() && - processingSet.processingByCombo().entrySet().stream() - .anyMatch(e -> retainCombos.contains(e.getKey()) && - e.getValue().mustBeEaten())) + if (isRetainedByCombos(processingSet, eatenKey, retainCombos)) continue; keysToRemove.add(eatenKey); } else { - if (!retainCombos.isEmpty() && - processingSet.processingByCombo().entrySet().stream() - .anyMatch(e -> retainCombos.contains(e.getKey()) && - e.getValue().mustBeEaten())) + if (isRetainedByCombos(processingSet, eatenKey, retainCombos)) continue; alsoRelease = releasingKey != null && releasingKey.equals(eatenKey); } @@ -371,6 +402,31 @@ private List buildRegurgitates(Key filterKey, Key releasingKey, return regurgitates; } + /** + * Precondition-only keys are tracked under dummyCombo, not the real combo, so they + * can't be matched by combo identity alone; check the combo's precondition keys + * directly for that case. + */ + private static boolean isRetainedByCombos(PressKeyEventProcessingSet processingSet, + Key eatenKey, Set retainCombos) { + if (retainCombos.isEmpty()) + return false; + if (processingSet.processingByCombo().entrySet().stream() + .anyMatch(e -> retainCombos.contains(e.getKey()) && + e.getValue().mustBeEaten())) + return true; + if (processingSet.processingByCombo().values().stream() + .anyMatch(PressKeyEventProcessing::isPartOfPressedComboPreconditionOnly)) { + return retainCombos.stream() + .anyMatch(combo -> combo.precondition() + .keyPrecondition() + .pressedKeyPrecondition() + .allKeys() + .contains(eatenKey)); + } + return false; + } + private void addRegurgitate(PressKeyEventProcessingSet processingSet, List regurgitates, Key eatenKey, boolean alsoRelease) { @@ -383,7 +439,8 @@ private void addRegurgitate(PressKeyEventProcessingSet processingSet, processingSet.processingByCombo().entrySet())) { Combo combo = entry.getKey(); PressKeyEventProcessing processing = entry.getValue(); - if (processing.isPartOfComboSequence()) + if (processing.isPartOfComboSequence() || + processing.isPartOfPressedComboPreconditionOnly()) processingSet.processingByCombo() .put(combo, PressKeyEventProcessing.partOfComboSequence( @@ -480,6 +537,12 @@ private List handleDeadEatingCombos(Set deadCombos, Key eatenKey = entry.getKey(); Eat eat = entry.getValue(); PressKeyEventProcessingSet ps = eat.processingSet(); + // Precondition-only keys are tracked under dummyCombo, not a real Combo, so + // they never appear in deadCombos/viableCombos and must be skipped here: + // their own combo's liveness is checked directly (isRetainedByCombos) when + // they're released, not via this dead-combo-identity tracking. + if (ps.isPartOfPressedComboPreconditionOnly()) + continue; // Check if any viable (non-dead) eating combo remains. boolean hasViableEatingCombo = ps.processingByCombo().entrySet() .stream() @@ -516,9 +579,21 @@ private List handleDeadEatingCombos(Set deadCombos, private void clearFullyCompletedEatenKeys() { eatenKeys.entrySet().removeIf(entry -> entry.getValue().processingSet().processingByCombo().values().stream() - .allMatch(p -> !p.isPartOfComboSequence() || - p.isPartOfCompletedComboSequence() || - !p.mustBeEaten())); + .allMatch(KeyboardManager::isClearedProcessing)); + } + + /** + * Precondition-only keys never satisfy isPartOfComboSequence(), so they need their + * own clearable check: clearable once no longer mustBeEaten (rewritten to a + * completed sequence entry by markOtherKeysOfTheseCombosAsCompleted once their + * combo fires). + */ + private static boolean isClearedProcessing(PressKeyEventProcessing p) { + if (p.isPartOfPressedComboPreconditionOnly()) + return !p.mustBeEaten(); + return !p.isPartOfComboSequence() || + p.isPartOfCompletedComboSequence() || + !p.mustBeEaten(); } } diff --git a/src/main/java/mousemaster/MouseManager.java b/src/main/java/mousemaster/MouseManager.java index 063a9e5c..385bd0b0 100644 --- a/src/main/java/mousemaster/MouseManager.java +++ b/src/main/java/mousemaster/MouseManager.java @@ -41,6 +41,10 @@ public class MouseManager implements ModeListener, MousePositionListener { private boolean lastWheelXActive, lastWheelXForward; private boolean lastWheelYActive, lastWheelYForward; + // Grace period past a jump's expected duration before giving up on it, so a jump + // whose target the platform never confirms can't stall mouse input forever. + private static final double JUMP_ARRIVAL_TIMEOUT_SECONDS = 0.5; + private int mouseX, mouseY; private boolean jumping; private double jumpDuration; @@ -190,32 +194,39 @@ else if (!jumping) double jumpTotalDuration = Math.hypot(jumpEndX - jumpBeginX, jumpEndY - jumpBeginY) / jumpVelocity; - double percent = Math.min(1, jumpDuration / jumpTotalDuration); - percent = new Easing.Smootherstep().apply(percent); - int nextJumpX = (int) (jumpBeginX + (jumpEndX - jumpBeginX) * percent); - int nextJumpY = (int) (jumpBeginY + (jumpEndY - jumpBeginY) * percent); - // Merge the user movement in. - if (percent != 1) { - if (jumpX == jumpEndX) { - int movingDeltaX = xMoveForwardStack.isEmpty() ? 0 : - (int) (deltaDistanceX * (xMoveForwardStack.peek() ? 1 : -1)); - deltaDistanceX -= (int) deltaDistanceX; - nextJumpX += movingDeltaX; - jumpEndX += movingDeltaX; + if (jumpDuration > jumpTotalDuration + JUMP_ARRIVAL_TIMEOUT_SECONDS) { + // Timed out waiting for mouseMoved() to confirm arrival - give up. + jumping = false; + jumpDuration = 0; + } + else { + double percent = Math.min(1, jumpDuration / jumpTotalDuration); + percent = new Easing.Smootherstep().apply(percent); + int nextJumpX = (int) (jumpBeginX + (jumpEndX - jumpBeginX) * percent); + int nextJumpY = (int) (jumpBeginY + (jumpEndY - jumpBeginY) * percent); + // Merge the user movement in. + if (percent != 1) { + if (jumpX == jumpEndX) { + int movingDeltaX = xMoveForwardStack.isEmpty() ? 0 : + (int) (deltaDistanceX * (xMoveForwardStack.peek() ? 1 : -1)); + deltaDistanceX -= (int) deltaDistanceX; + nextJumpX += movingDeltaX; + jumpEndX += movingDeltaX; + } + if (jumpY == jumpEndY) { + int movingDeltaY = yMoveForwardStack.isEmpty() ? 0 : + (int) (deltaDistanceY * (yMoveForwardStack.peek() ? 1 : -1)); + deltaDistanceY -= (int) deltaDistanceY; + nextJumpY += movingDeltaY; + jumpEndY += movingDeltaY; + } } - if (jumpY == jumpEndY) { - int movingDeltaY = yMoveForwardStack.isEmpty() ? 0 : - (int) (deltaDistanceY * (yMoveForwardStack.peek() ? 1 : -1)); - deltaDistanceY -= (int) deltaDistanceY; - nextJumpY += movingDeltaY; - jumpEndY += movingDeltaY; + if (nextJumpX != jumpX || nextJumpY != jumpY) { + mouseController.synchronousMoveTo(nextJumpX, nextJumpY); + jumpX = nextJumpX; + jumpY = nextJumpY; } } - if (nextJumpX != jumpX || nextJumpY != jumpY) { - mouseController.synchronousMoveTo(nextJumpX, nextJumpY); - jumpX = nextJumpX; - jumpY = nextJumpY; - } } if (activelyWheeling()) { wheelDuration += delta; diff --git a/src/main/java/mousemaster/Mousemaster.java b/src/main/java/mousemaster/Mousemaster.java index fdf60648..21425b5a 100644 --- a/src/main/java/mousemaster/Mousemaster.java +++ b/src/main/java/mousemaster/Mousemaster.java @@ -185,26 +185,9 @@ private void loadConfiguration(boolean readFile) throws IOException { HintManager hintManager = new HintManager(configuration.maxPositionHistorySize(), screenManager, mouseManager, platform.overlay(), platform.uiAutomation()); commandRunner = new CommandRunner(mouseManager, gridManager, hintManager); - Set unpressedComboPreconditionKeys = new HashSet<>(); - Set pressedComboPreconditionKeys = new HashSet<>(); - for (Mode mode : configuration.modeMap().modes()) { - for (Combo combo : mode.comboMap().commandsByCombo().keySet()) { - unpressedComboPreconditionKeys.addAll(combo.precondition() - .keyPrecondition() - .unpressedKeySet() - .stream() - .toList()); - pressedComboPreconditionKeys.addAll(combo.precondition() - .keyPrecondition() - .pressedKeyPrecondition() - .allKeys()); - } - } ComboWatcher comboWatcher = new ComboWatcher(commandRunner, hintManager, platform.activeAppFinder(), - platform.clock(), - unpressedComboPreconditionKeys, - pressedComboPreconditionKeys, configuration.logRedactKeys(), + platform.clock(), configuration.logRedactKeys(), configuration.modeMap()); keyboardManager = new KeyboardManager(comboWatcher, hintManager, platform.keyRegurgitator()); diff --git a/src/main/java/mousemaster/MousemasterApplication.java b/src/main/java/mousemaster/MousemasterApplication.java index 7c6cfeaa..562a6981 100644 --- a/src/main/java/mousemaster/MousemasterApplication.java +++ b/src/main/java/mousemaster/MousemasterApplication.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.bridge.SLF4JBridgeHandler; +import java.io.File; import java.util.Scanner; import java.util.logging.LogManager; @@ -42,9 +43,11 @@ public static void setTempDirectory(String tempDirectory) { } if (MousemasterApplication.tempDirectory == null) MousemasterApplication.tempDirectory = - System.getProperty("java.io.tmpdir") + "mousemaster-" + + System.getProperty("java.io.tmpdir") + File.separator + "mousemaster-" + System.getProperty("user.name").hashCode(); - System.setProperty("jna.tmpdir", MousemasterApplication.tempDirectory + "/jna"); + String jnaTmpDir = MousemasterApplication.tempDirectory + "/jna"; + System.setProperty("jna.tmpdir", jnaTmpDir); + new File(jnaTmpDir).mkdirs(); } public static void shutdownAfterException(Throwable e, Platform platform, diff --git a/src/main/java/mousemaster/PressKeyEventProcessing.java b/src/main/java/mousemaster/PressKeyEventProcessing.java index d2b8d026..8efe6869 100644 --- a/src/main/java/mousemaster/PressKeyEventProcessing.java +++ b/src/main/java/mousemaster/PressKeyEventProcessing.java @@ -1,5 +1,8 @@ package mousemaster; +// TODO: modified in commit c85df26 to fix Linux key masking (precondition keys leaking +// to the OS with no Windows-style focus-stealing to hide it). Shared, non-platform code +// affecting Windows too — confirm this is safe/valid here vs. Linux-specific before merge. public enum PressKeyEventProcessing { UNHANDLED, @@ -8,6 +11,7 @@ public enum PressKeyEventProcessing { PART_OF_COMPLETED_COMBO_SEQUENCE_MUST_NOT_BE_EATEN, PART_OF_COMPLETED_COMBO_SEQUENCE_MUST_BE_EATEN, PART_OF_PRESSED_COMBO_PRECONDITION_ONLY, // "Only" means it is not part of a combo sequence (it is just part of a combo precondition). + PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN, // Same as above, but the key must be eaten speculatively (e.g. space held down for a chord whose other key hasn't been pressed yet). PART_OF_UNPRESSED_COMBO_PRECONDITION_ONLY, IGNORED_BY_LEADING_WAIT_MUST_NOT_BE_EATEN, IGNORED_BY_LEADING_WAIT_MUST_BE_EATEN, @@ -25,7 +29,8 @@ public boolean mustBeEaten() { this == IGNORED_BY_LEADING_WAIT_MUST_BE_EATEN || this == HINT_UNDO_MUST_BE_EATEN || this == UNSWALLOWED_HINT_END_MUST_BE_EATEN || - this == UNUSED_HINT_SELECTION_KEY_MUST_BE_EATEN; + this == UNUSED_HINT_SELECTION_KEY_MUST_BE_EATEN || + this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN; } public boolean handled() { @@ -40,10 +45,16 @@ public boolean handled() { this == UNSWALLOWED_HINT_END_MUST_BE_EATEN || this == UNUSED_HINT_SELECTION_KEY_MUST_BE_EATEN || this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY || + this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN || this == IGNORED_BY_LEADING_WAIT_MUST_NOT_BE_EATEN || this == IGNORED_BY_LEADING_WAIT_MUST_BE_EATEN; } + // PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN is deliberately NOT part of + // isPartOfComboSequence(): it is tracked under the empty dummyCombo sequence, which + // can never match, so ComboWatcher.update()'s prefix check would immediately + // force-regurgitate the key. KeyboardManager instead handles it via + // isPartOfPressedComboPreconditionOnly() in clearFullyCompletedEatenKeys/buildRegurgitates. public boolean isPartOfComboSequence() { return this == PART_OF_COMBO_SEQUENCE_MUST_NOT_BE_EATEN || this == PART_OF_COMBO_SEQUENCE_MUST_BE_EATEN || @@ -62,7 +73,8 @@ public boolean isPartOfCompletedComboSequence() { } public boolean isPartOfPressedComboPreconditionOnly() { - return this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY; + return this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY || + this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN; } public boolean isPartOfUnpressedComboPreconditionOnly() { @@ -149,8 +161,9 @@ public static PressKeyEventProcessing ignoredByLeadingWait(boolean mustBeEaten) IGNORED_BY_LEADING_WAIT_MUST_NOT_BE_EATEN; } - public static PressKeyEventProcessing partOfPressedComboPreconditionOnly() { - return PART_OF_PRESSED_COMBO_PRECONDITION_ONLY; + public static PressKeyEventProcessing partOfPressedComboPreconditionOnly(boolean mustBeEaten) { + return mustBeEaten ? PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN : + PART_OF_PRESSED_COMBO_PRECONDITION_ONLY; } public static PressKeyEventProcessing partOfUnpressedComboPreconditionOnly() { diff --git a/src/main/java/mousemaster/QtManager.java b/src/main/java/mousemaster/QtManager.java index 8a3fe6ee..aff19e8f 100644 --- a/src/main/java/mousemaster/QtManager.java +++ b/src/main/java/mousemaster/QtManager.java @@ -9,16 +9,18 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; +import java.nio.file.*; import java.util.List; +import java.util.Map; public class QtManager { private static final Logger logger = LoggerFactory.getLogger(QtManager.class.getName()); + private static final boolean IS_LINUX = + System.getProperty("os.name").toLowerCase().contains("linux"); + + // Windows: Qt6 runtime DLLs bundled in src/main/resources/qt/bin/ private static final List windowsResourcesPaths = List.of( "qt/bin/Qt6Core.dll", "qt/bin/Qt6Gui.dll", @@ -33,7 +35,7 @@ public class QtManager { "qt/plugins/platforms/qwindowsd.dll" ); - private static final List qtJambiPaths = List.of( + private static final List qtJambiWindowsPaths = List.of( "bin/QtJambi6.dll", "bin/QtJambiCore6.dll", "bin/QtJambiGui6.dll", @@ -46,15 +48,40 @@ public class QtManager { "qt-msvcp/msvcp140_2.dll" ); + // Linux: QtJambi bridge .so files from qtjambi-native-linux-x64-6.8.2.jar + // Qt6 runtime (libQt6Core.so.6 etc.) comes from LD_LIBRARY_PATH (nix devshell / system) + private static final List qtJambiLinuxPaths = List.of( + "lib/libQtJambi.so.6.8.2", + "lib/libQtJambiCore.so.6.8.2", + "lib/libQtJambiGui.so.6.8.2", + "lib/libQtJambiWidgets.so.6.8.2", + "lib/libQtJambiGuiRhi.so.6.8.2" + ); + + // For each base name, the symlinks the dynamic linker needs (per qtjambi-deployment.xml) + private static final Map linuxSymlinkBases = Map.of( + "libQtJambi", "libQtJambi.so.6.8.2", + "libQtJambiCore", "libQtJambiCore.so.6.8.2", + "libQtJambiGui", "libQtJambiGui.so.6.8.2", + "libQtJambiWidgets", "libQtJambiWidgets.so.6.8.2", + "libQtJambiGuiRhi", "libQtJambiGuiRhi.so.6.8.2" + ); + public static void initialize() throws IOException { - File extractDirectory = createExtractDirectory( - MousemasterApplication.tempDirectory); + if (IS_LINUX) + initializeLinux(); + else + initializeWindows(); + } + + private static void initializeWindows() throws IOException { + File extractDirectory = createExtractDirectory(MousemasterApplication.tempDirectory); for (String resourcesPath : windowsResourcesPaths) { Path extractPath = Paths.get(extractDirectory.getAbsolutePath() + "/" + resourcesPath); Files.createDirectories(extractPath.getParent()); extractResourceFile(resourcesPath, extractPath); } - for (String qtJambiPath : qtJambiPaths) { + for (String qtJambiPath : qtJambiWindowsPaths) { Path extractPath = Paths.get(extractDirectory.getAbsolutePath() + "/qt/" + qtJambiPath); extractResourceFile(qtJambiPath, extractPath); } @@ -62,14 +89,7 @@ public static void initialize() throws IOException { System.setProperty("io.qt.library-path-override", extractDirectory.getAbsolutePath() + "/qt/bin"); // QtJambi expects DLLs in io.qt.library-path-override, and io.qt.library-path-override/../plugins/platforms - -// System.setProperty("QT_ENABLE_HIGHDPI_SCALING", "0"); -// setEnv("QT_ENABLE_HIGHDPI_SCALING", "0"); -// System.setProperty("QT_AUTO_SCREEN_SCALE_FACTOR", "0"); -// System.setProperty("QT_SCALE_FACTOR", "1"); - // https://forum.qt.io/topic/141511/qt_enable_highdpi_scaling-has-no-effect try { - // Just to trigger the static initializer which loads DLLs. QtUtilities.jambiDeploymentDir(); } catch (UnsatisfiedLinkError e) { for (String msvcpDllResourcePath : msvcpDllPaths) { @@ -81,11 +101,50 @@ public static void initialize() throws IOException { e2.setStackTrace(e.getStackTrace()); throw e2; } - QtUtilities.putenv("QT_ENABLE_HIGHDPI_SCALING", "0"); // Only works on Windows? + QtUtilities.putenv("QT_ENABLE_HIGHDPI_SCALING", "0"); logger.trace("highDpiScaleFactorRoundingPolicy is " + QApplication.highDpiScaleFactorRoundingPolicy()); - // Default font engine on Windows is directwrite. Antialiasing seems better with gdi. QApplication.initialize(new String[] { "-platform", "windows:fontengine=gdi" }); -// QApplication.initialize(new String[] { }); + } + + private static void initializeLinux() throws IOException { + File extractDirectory = createExtractDirectory(MousemasterApplication.tempDirectory); + Path libDir = Paths.get(extractDirectory.getAbsolutePath(), "qt", "lib"); + Files.createDirectories(libDir); + + // Extract QtJambi bridge .so files from the qtjambi-native-linux-x64 JAR on classpath + for (String qtJambiPath : qtJambiLinuxPaths) { + String fileName = qtJambiPath.substring(qtJambiPath.lastIndexOf('/') + 1); + Path extractPath = libDir.resolve(fileName); + extractResourceFile(qtJambiPath, extractPath); + } + + // Create SONAME symlinks required by the dynamic linker (from qtjambi-deployment.xml) + for (Map.Entry entry : linuxSymlinkBases.entrySet()) { + String base = entry.getKey(); + String target = entry.getValue(); + createSymlinkIfAbsent(libDir.resolve(base + ".so"), libDir.resolve(target)); + createSymlinkIfAbsent(libDir.resolve(base + ".so.6"), libDir.resolve(target)); + createSymlinkIfAbsent(libDir.resolve(base + ".so.6.8"), libDir.resolve(target)); + } + + logger.trace("Extracted QtJambi .so files to " + libDir); + System.setProperty("io.qt.library-path-override", libDir.toString()); + // Qt6 runtime (libQt6Core.so.6 etc.) resolved via LD_LIBRARY_PATH from nix devshell / system + QtUtilities.jambiDeploymentDir(); + logger.trace("highDpiScaleFactorRoundingPolicy is " + QApplication.highDpiScaleFactorRoundingPolicy()); + QApplication.initialize(new String[]{}); + } + + private static void createSymlinkIfAbsent(Path link, Path target) { + try { + // Use relative target so the symlinks work regardless of temp dir path + Path relativeTarget = link.getParent().relativize(target); + Files.createSymbolicLink(link, relativeTarget); + } catch (FileAlreadyExistsException ignored) { + // Already exists from a prior run — fine + } catch (IOException e) { + logger.warn("Could not create symlink {} -> {}: {}", link, target, e.getMessage()); + } } private static void extractResourceFile(String resourcesPath, Path extractPath) @@ -97,8 +156,7 @@ private static void extractResourceFile(String resourcesPath, Path extractPath) StandardOpenOption.WRITE)) { inputStream.transferTo(outputStream); } catch (IOException e) { - // java.nio.file.FileSystemException: C:\Users\x\AppData\Local\Temp\mousemaster-110364797\qt\bin\Qt6Core.dll: The process cannot access the file because it is being used by another process - // logger.debug("Unable to extract resource file " + resourcesPath, e); + // File in use (Windows: DLL loaded by a prior process instance) — skip silently } } } diff --git a/src/main/java/mousemaster/platform/MouseController.java b/src/main/java/mousemaster/platform/MouseController.java index 8a5fc977..09d7e60e 100644 --- a/src/main/java/mousemaster/platform/MouseController.java +++ b/src/main/java/mousemaster/platform/MouseController.java @@ -29,4 +29,7 @@ public interface MouseController { void showCursor(); void hideCursor(); + + default void destroy() { + } } diff --git a/src/main/java/mousemaster/platform/linux/LibUinput.java b/src/main/java/mousemaster/platform/linux/LibUinput.java new file mode 100644 index 00000000..d2c66430 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibUinput.java @@ -0,0 +1,194 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLong; +import com.sun.jna.Pointer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; + +/** + * Helper for Linux uinput virtual input device. + * Provides mouse button clicks and scroll wheel injection that works on + * both X11 and native Wayland (unlike XTest which only reaches X11 clients). + */ +public class LibUinput { + + private static final Logger logger = LoggerFactory.getLogger(LibUinput.class); + + // ioctl numbers for uinput on x86_64 Linux + // Computed via _IOW('U', nr, int) and _IO('U', nr) + static final long UI_SET_EVBIT = 0x40045564L; // _IOW('U', 100, int) + static final long UI_SET_KEYBIT = 0x40045565L; // _IOW('U', 101, int) + static final long UI_SET_RELBIT = 0x40045566L; // _IOW('U', 102, int) + static final long UI_DEV_CREATE = 0x5501L; // _IO('U', 1) + static final long UI_DEV_DESTROY = 0x5502L; // _IO('U', 2) + + // Event types (linux/input-event-codes.h) + static final int EV_SYN = 0; + static final int EV_KEY = 1; + static final int EV_REL = 2; + + static final int SYN_REPORT = 0; + + // Mouse button codes + static final int BTN_LEFT = 0x110; + static final int BTN_RIGHT = 0x111; + static final int BTN_MIDDLE = 0x112; + + // Relative axis codes + static final int REL_X = 0; + static final int REL_Y = 1; + static final int REL_HWHEEL = 6; + static final int REL_WHEEL = 8; + + static final short BUS_VIRTUAL = 0x06; + + // open() flags on Linux x86_64 + static final int O_WRONLY = 1; + static final int O_NONBLOCK = 0x800; + + // sizeof(uinput_user_dev): 80 (name) + 8 (input_id) + 4 (ff_effects_max) + 4*256 (abs arrays) = 1116 + private static final int UINPUT_USER_DEV_SIZE = 1116; + + // sizeof(input_event) on x86_64: 16 (timeval) + 2 (type) + 2 (code) + 4 (value) = 24 + static final int INPUT_EVENT_SIZE = 24; + + static final String UINPUT_PATH = "/dev/uinput"; + // Distinct names let the evdev read-loop filter out these virtual devices + static final String DEVICE_NAME = "mousemaster-mouse"; + static final String KEYBOARD_DEVICE_NAME = "mousemaster-kb"; + + interface CLib extends Library { + CLib INSTANCE = Native.load("c", CLib.class); + + int open(String path, int flags); + int close(int fd); + NativeLong write(int fd, Pointer buf, NativeLong count); + int ioctl(int fd, NativeLong request, int arg); + } + + static int createKeyboardDevice() { + int fd = CLib.INSTANCE.open(UINPUT_PATH, O_WRONLY | O_NONBLOCK); + if (fd < 0) { + logger.error("Cannot open {} for keyboard device (errno={})", UINPUT_PATH, Native.getLastError()); + return -1; + } + + requireIoctl(fd, UI_SET_EVBIT, EV_KEY, "UI_SET_EVBIT(EV_KEY)"); + requireIoctl(fd, UI_SET_EVBIT, EV_SYN, "UI_SET_EVBIT(EV_SYN)"); + // Enable all standard key codes (1–255 covers the full QWERTY + function + numpad range) + for (int code = 1; code <= 255; code++) + CLib.INSTANCE.ioctl(fd, new NativeLong(UI_SET_KEYBIT), code); + + Memory userDev = new Memory(UINPUT_USER_DEV_SIZE); + userDev.clear(); + byte[] nameBytes = KEYBOARD_DEVICE_NAME.getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i < nameBytes.length && i < 79; i++) + userDev.setByte(i, nameBytes[i]); + userDev.setShort(80, BUS_VIRTUAL); + NativeLong written = CLib.INSTANCE.write(fd, userDev, new NativeLong(UINPUT_USER_DEV_SIZE)); + if (written.longValue() != UINPUT_USER_DEV_SIZE) { + logger.error("uinput_user_dev write failed for keyboard device: wrote {} of {} bytes (errno={})", + written.longValue(), UINPUT_USER_DEV_SIZE, Native.getLastError()); + CLib.INSTANCE.close(fd); + return -1; + } + + int result = CLib.INSTANCE.ioctl(fd, new NativeLong(UI_DEV_CREATE), 0); + if (result < 0) { + logger.error("UI_DEV_CREATE failed for keyboard device (errno={})", Native.getLastError()); + CLib.INSTANCE.close(fd); + return -1; + } + + logger.info("uinput keyboard device '{}' created (fd={})", KEYBOARD_DEVICE_NAME, fd); + return fd; + } + + static int createMouseDevice() { + int fd = CLib.INSTANCE.open(UINPUT_PATH, O_WRONLY | O_NONBLOCK); + if (fd < 0) { + logger.error("Cannot open {} (errno likely EACCES or ENOENT).\n" + + "Add yourself to the 'uinput' group:\n" + + " sudo usermod -aG uinput $USER (then log out and back in)", + UINPUT_PATH); + return -1; + } + + requireIoctl(fd, UI_SET_EVBIT, EV_KEY, "UI_SET_EVBIT(EV_KEY)"); + requireIoctl(fd, UI_SET_EVBIT, EV_SYN, "UI_SET_EVBIT(EV_SYN)"); + requireIoctl(fd, UI_SET_EVBIT, EV_REL, "UI_SET_EVBIT(EV_REL)"); + requireIoctl(fd, UI_SET_KEYBIT, BTN_LEFT, "UI_SET_KEYBIT(BTN_LEFT)"); + requireIoctl(fd, UI_SET_KEYBIT, BTN_RIGHT, "UI_SET_KEYBIT(BTN_RIGHT)"); + requireIoctl(fd, UI_SET_KEYBIT, BTN_MIDDLE,"UI_SET_KEYBIT(BTN_MIDDLE)"); + requireIoctl(fd, UI_SET_RELBIT, REL_X, "UI_SET_RELBIT(REL_X)"); + requireIoctl(fd, UI_SET_RELBIT, REL_Y, "UI_SET_RELBIT(REL_Y)"); + requireIoctl(fd, UI_SET_RELBIT, REL_WHEEL, "UI_SET_RELBIT(REL_WHEEL)"); + requireIoctl(fd, UI_SET_RELBIT, REL_HWHEEL,"UI_SET_RELBIT(REL_HWHEEL)"); + + // Write uinput_user_dev to configure device name and bus type + Memory userDev = new Memory(UINPUT_USER_DEV_SIZE); + userDev.clear(); + byte[] nameBytes = DEVICE_NAME.getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i < nameBytes.length && i < 79; i++) { + userDev.setByte(i, nameBytes[i]); + } + userDev.setShort(80, BUS_VIRTUAL); // input_id.bustype at offset 80 + NativeLong written = CLib.INSTANCE.write(fd, userDev, new NativeLong(UINPUT_USER_DEV_SIZE)); + if (written.longValue() != UINPUT_USER_DEV_SIZE) { + logger.error("uinput_user_dev write failed: wrote {} of {} bytes (errno={})", + written.longValue(), UINPUT_USER_DEV_SIZE, Native.getLastError()); + CLib.INSTANCE.close(fd); + return -1; + } + logger.debug("uinput_user_dev write ok ({} bytes)", written.longValue()); + + int result = CLib.INSTANCE.ioctl(fd, new NativeLong(UI_DEV_CREATE), 0); + if (result < 0) { + logger.error("UI_DEV_CREATE ioctl failed (result={})", result); + CLib.INSTANCE.close(fd); + return -1; + } + + logger.info("uinput mouse device '{}' created (fd={})", DEVICE_NAME, fd); + return fd; + } + + static void destroyDevice(int fd) { + if (fd >= 0) { + CLib.INSTANCE.ioctl(fd, new NativeLong(UI_DEV_DESTROY), 0); + CLib.INSTANCE.close(fd); + logger.info("uinput device destroyed (fd={})", fd); + } + } + + private static void requireIoctl(int fd, long request, int arg, String name) { + int r = CLib.INSTANCE.ioctl(fd, new NativeLong(request), arg); + if (r < 0) { + int errno = Native.getLastError(); + logger.error("ioctl {} failed: result={} errno={}", name, r, errno); + } else { + logger.debug("ioctl {} ok", name); + } + } + + static NativeLong writeInputEvent(int fd, int type, int code, int value) { + if (fd < 0) return new NativeLong(-1); + Memory event = new Memory(INPUT_EVENT_SIZE); + event.clear(); // timeval is zeroed; kernel accepts zero timestamps for synthetic events + event.setShort(16, (short) type); + event.setShort(18, (short) code); + event.setInt(20, value); + NativeLong written = CLib.INSTANCE.write(fd, event, new NativeLong(INPUT_EVENT_SIZE)); + if (written.longValue() != INPUT_EVENT_SIZE) { + int errno = Native.getLastError(); + logger.warn("write() for input_event (type={}, code={}, value={}) wrote {} of {} bytes (errno={})", + type, code, value, written.longValue(), INPUT_EVENT_SIZE, errno); + } + return written; + } +} diff --git a/src/main/java/mousemaster/platform/linux/LibWaylandClient.java b/src/main/java/mousemaster/platform/linux/LibWaylandClient.java new file mode 100644 index 00000000..8434774f --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibWaylandClient.java @@ -0,0 +1,150 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Callback; +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLibrary; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Generic, protocol-agnostic JNA bindings for libwayland-client.so: connection + * lifecycle and the wire-level proxy marshalling API. Protocol-specific requests + * (e.g. zwlr_virtual_pointer_v1) are built on top of this in LibWlrVirtualPointer. + */ +public interface LibWaylandClient extends Library { + + LibWaylandClient INSTANCE = Native.load("wayland-client", LibWaylandClient.class); + + int WL_MARSHAL_FLAG_DESTROY = 1; + + // Core wl_display/wl_registry opcodes (wayland.xml), not specific to any extension. + int WL_DISPLAY_GET_REGISTRY = 1; // signature "n" + int WL_REGISTRY_BIND = 0; // signature "usun" (special-cased dynamic bind) + + Pointer wl_display_connect(String name); + void wl_display_disconnect(Pointer display); + int wl_display_dispatch(Pointer display); + int wl_display_roundtrip(Pointer display); + int wl_display_flush(Pointer display); + + Pointer wl_proxy_marshal_array_flags(Pointer proxy, int opcode, Pointer interface_, + int version, int flags, Pointer args); + int wl_proxy_add_listener(Pointer proxy, Pointer implementation, Pointer data); + void wl_proxy_destroy(Pointer proxy); + int wl_proxy_get_version(Pointer proxy); + + @Structure.FieldOrder({"name", "signature", "types"}) + class WlMessage extends Structure { + public String name; + public String signature; + public Pointer types; + + public WlMessage() { + super(); + } + + public WlMessage(Pointer p) { + super(p); + } + } + + @Structure.FieldOrder({"name", "version", "method_count", "methods", "event_count", "events"}) + class WlInterface extends Structure { + public String name; + public int version; + public int method_count; + public Pointer methods; + public int event_count; + public Pointer events; + } + + interface WlRegistryGlobalCallback extends Callback { + void invoke(Pointer data, Pointer registry, int name, String interfaceName, int version); + } + + interface WlRegistryGlobalRemoveCallback extends Callback { + void invoke(Pointer data, Pointer registry, int name); + } + + @Structure.FieldOrder({"global", "globalRemove"}) + class WlRegistryListener extends Structure { + public WlRegistryGlobalCallback global; + public WlRegistryGlobalRemoveCallback globalRemove; + } + + /** + * Raw union wl_argument[] builder (8-byte slots on x86_64, since the union's + * widest members are pointers). Every slot in a message's signature needs an + * entry here, including the new_id slot native code fills in automatically. + */ + class WlArgs { + private static final int SLOT_SIZE = 8; + private final Memory mem; + private final List stringBacking = new ArrayList<>(); + + public WlArgs(int slotCount) { + mem = new Memory((long) SLOT_SIZE * Math.max(slotCount, 1)); + mem.clear(); + } + + public WlArgs setUint(int slot, int value) { + mem.setInt((long) slot * SLOT_SIZE, value); + return this; + } + + public WlArgs setInt(int slot, int value) { + mem.setInt((long) slot * SLOT_SIZE, value); + return this; + } + + public WlArgs setFixed(int slot, int fixedValue) { + mem.setInt((long) slot * SLOT_SIZE, fixedValue); + return this; + } + + public WlArgs setString(int slot, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + Memory str = new Memory(bytes.length + 1L); + str.write(0, bytes, 0, bytes.length); + str.setByte(bytes.length, (byte) 0); + stringBacking.add(str); + mem.setPointer((long) slot * SLOT_SIZE, str); + return this; + } + + public WlArgs setObject(int slot, Pointer value) { + mem.setPointer((long) slot * SLOT_SIZE, value); + return this; + } + + public Pointer pointer() { + return mem; + } + } + + static int fixedFromInt(int value) { + return value * 256; + } + + static int fixedFromDouble(double value) { + return (int) Math.round(value * 256.0); + } + + static int fixedToInt(int fixedValue) { + return fixedValue / 256; + } + + static double fixedToDouble(int fixedValue) { + return fixedValue / 256.0; + } + + static Pointer globalInterfacePointer(String symbolName) { + return NativeLibrary.getInstance("wayland-client").getGlobalVariableAddress(symbolName); + } +} diff --git a/src/main/java/mousemaster/platform/linux/LibWlrVirtualPointer.java b/src/main/java/mousemaster/platform/linux/LibWlrVirtualPointer.java new file mode 100644 index 00000000..cd40a046 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibWlrVirtualPointer.java @@ -0,0 +1,124 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Memory; +import com.sun.jna.Pointer; + +/** + * Hand-rolled wl_interface/wl_message data for zwlr_virtual_pointer_manager_v1 and + * zwlr_virtual_pointer_v1 (wlr-protocols, version 2 of each). Neither interface has + * any events, so the "types" arrays below are populated for correctness/parity with + * real wayland-scanner output but are never actually read by outbound marshalling. + */ +final class LibWlrVirtualPointer { + + static final int VP_OP_MOTION = 0; // "uff" + static final int VP_OP_MOTION_ABSOLUTE = 1; // "uuuuu" + static final int VP_OP_BUTTON = 2; // "uuu" (unused, see WaylandMouse) + static final int VP_OP_AXIS = 3; // "uuf" (unused) + static final int VP_OP_FRAME = 4; // "" + static final int VP_OP_AXIS_SOURCE = 5; // "u" (unused) + static final int VP_OP_AXIS_STOP = 6; // "uu" (unused) + static final int VP_OP_AXIS_DISCRETE = 7; // "uufi" (unused) + static final int VP_OP_DESTROY = 8; // "" + + static final int MGR_OP_CREATE_VIRTUAL_POINTER = 0; // "?on" + static final int MGR_OP_DESTROY = 1; // "" + static final int MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT = 2; // "?o?on" (unused) + + static final LibWaylandClient.WlInterface ZWLR_VIRTUAL_POINTER_V1_INTERFACE = + buildVirtualPointerV1Interface(); + static final LibWaylandClient.WlInterface ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE = + buildManagerInterface(); + + // Must stay reachable for the process lifetime: iface.methods only retains the raw + // native pointer to element 0, not the individual WlMessage Java objects, which are + // what keep their name/signature string-backing Memory alive. Without these fields, + // methods[1..] get GC'd once the builders below return, their string memory gets + // freed and later reused by unrelated allocations, and native code ends up reading + // corrupted method names/signatures out of the (still-contiguous) methods array. + private static LibWaylandClient.WlMessage[] virtualPointerV1Methods; + private static LibWaylandClient.WlMessage[] managerMethods; + + private LibWlrVirtualPointer() { + } + + private static LibWaylandClient.WlMessage[] buildMessages(int count) { + LibWaylandClient.WlMessage first = new LibWaylandClient.WlMessage(); + return (LibWaylandClient.WlMessage[]) first.toArray(count); + } + + private static Pointer typesArray(Pointer... interfacePointers) { + Memory mem = new Memory(8L * interfacePointers.length); + for (int i = 0; i < interfacePointers.length; i++) + mem.setPointer(8L * i, interfacePointers[i]); + return mem; + } + + private static LibWaylandClient.WlInterface buildVirtualPointerV1Interface() { + LibWaylandClient.WlMessage[] methods = buildMessages(9); + methods[VP_OP_MOTION].name = "motion"; + methods[VP_OP_MOTION].signature = "uff"; + methods[VP_OP_MOTION_ABSOLUTE].name = "motion_absolute"; + methods[VP_OP_MOTION_ABSOLUTE].signature = "uuuuu"; + methods[VP_OP_BUTTON].name = "button"; + methods[VP_OP_BUTTON].signature = "uuu"; + methods[VP_OP_AXIS].name = "axis"; + methods[VP_OP_AXIS].signature = "uuf"; + methods[VP_OP_FRAME].name = "frame"; + methods[VP_OP_FRAME].signature = ""; + methods[VP_OP_AXIS_SOURCE].name = "axis_source"; + methods[VP_OP_AXIS_SOURCE].signature = "u"; + methods[VP_OP_AXIS_STOP].name = "axis_stop"; + methods[VP_OP_AXIS_STOP].signature = "uu"; + methods[VP_OP_AXIS_DISCRETE].name = "axis_discrete"; + methods[VP_OP_AXIS_DISCRETE].signature = "uufi"; + methods[VP_OP_DESTROY].name = "destroy"; + methods[VP_OP_DESTROY].signature = ""; + for (LibWaylandClient.WlMessage m : methods) { + m.types = Pointer.NULL; + m.write(); + } + + LibWaylandClient.WlInterface iface = new LibWaylandClient.WlInterface(); + iface.name = "zwlr_virtual_pointer_v1"; + iface.version = 2; + iface.method_count = methods.length; + iface.methods = methods[0].getPointer(); + iface.event_count = 0; + iface.events = Pointer.NULL; + iface.write(); + virtualPointerV1Methods = methods; + return iface; + } + + private static LibWaylandClient.WlInterface buildManagerInterface() { + Pointer wlSeatInterface = LibWaylandClient.globalInterfacePointer("wl_seat_interface"); + Pointer wlOutputInterface = LibWaylandClient.globalInterfacePointer("wl_output_interface"); + Pointer vp1Interface = ZWLR_VIRTUAL_POINTER_V1_INTERFACE.getPointer(); + + LibWaylandClient.WlMessage[] methods = buildMessages(3); + methods[MGR_OP_CREATE_VIRTUAL_POINTER].name = "create_virtual_pointer"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER].signature = "?on"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER].types = typesArray(wlSeatInterface, vp1Interface); + methods[MGR_OP_DESTROY].name = "destroy"; + methods[MGR_OP_DESTROY].signature = ""; + methods[MGR_OP_DESTROY].types = Pointer.NULL; + methods[MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT].name = "create_virtual_pointer_with_output"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT].signature = "?o?on"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT].types = + typesArray(wlSeatInterface, wlOutputInterface, vp1Interface); + for (LibWaylandClient.WlMessage m : methods) + m.write(); + + LibWaylandClient.WlInterface iface = new LibWaylandClient.WlInterface(); + iface.name = "zwlr_virtual_pointer_manager_v1"; + iface.version = 2; + iface.method_count = methods.length; + iface.methods = methods[0].getPointer(); + iface.event_count = 0; + iface.events = Pointer.NULL; + iface.write(); + managerMethods = methods; + return iface; + } +} diff --git a/src/main/java/mousemaster/platform/linux/LibX11.java b/src/main/java/mousemaster/platform/linux/LibX11.java new file mode 100644 index 00000000..8c5933ca --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibX11.java @@ -0,0 +1,199 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.LongByReference; +import com.sun.jna.ptr.PointerByReference; + +/** + * JNA bindings for Xlib (libX11.so) + */ +public interface LibX11 extends Library { + + LibX11 INSTANCE = Native.load("X11", LibX11.class); + + // Display management + Pointer XOpenDisplay(String displayName); + int XCloseDisplay(Pointer display); + int XDefaultScreen(Pointer display); + long XDefaultRootWindow(Pointer display); + int XFlush(Pointer display); + int XSync(Pointer display, boolean discard); + + // Atom management + long XInternAtom(Pointer display, String atomName, boolean onlyIfExists); + + // Window properties + int XChangeProperty(Pointer display, long window, long property, long type, + int format, int mode, byte[] data, int nelements); + + int XGetWindowProperty(Pointer display, long window, long property, + long longOffset, long longLength, boolean delete, + long reqType, LongByReference actualTypeReturn, + IntByReference actualFormatReturn, + LongByReference nitemsReturn, + LongByReference bytesAfterReturn, + PointerByReference propReturn); + + // Mouse pointer control + int XWarpPointer(Pointer display, long srcWindow, long destWindow, + int srcX, int srcY, int srcWidth, int srcHeight, + int destX, int destY); + + boolean XQueryPointer(Pointer display, long window, + LongByReference rootReturn, LongByReference childReturn, + IntByReference rootXReturn, IntByReference rootYReturn, + IntByReference winXReturn, IntByReference winYReturn, + IntByReference maskReturn); + + // Window management + int XGetWindowAttributes(Pointer display, long window, XWindowAttributes attributesReturn); + + // Cursor management + long XCreateBitmapFromData(Pointer display, long drawable, byte[] data, int width, int height); + long XCreatePixmapCursor(Pointer display, long source, long mask, + XColor fg, XColor bg, int x, int y); + int XDefineCursor(Pointer display, long window, long cursor); + int XUndefineCursor(Pointer display, long window); + int XFreePixmap(Pointer display, long pixmap); + int XFreeCursor(Pointer display, long cursor); + + // Memory management + int XFree(Pointer data); + + // Event types + int KeyPress = 2; + int KeyRelease = 3; + + // Event handling + int XPending(Pointer display); + int XNextEvent(Pointer display, XEvent eventReturn); + String XKeysymToString(long keysym); + long XLookupKeysym(XKeyEvent event, int index); + + // Keyboard grabbing + int XGrabKeyboard(Pointer display, long window, int ownerEvents, + int pointerMode, int keyboardMode, long time); + void XUngrabKeyboard(Pointer display, long time); + + // Event masks + void XSelectInput(Pointer display, long window, long eventMask); + + // Grab modes + int GrabModeSync = 0; + int GrabModeAsync = 1; + + // Grab status + int GrabSuccess = 0; + int AlreadyGrabbed = 1; + int GrabInvalidTime = 2; + int GrabNotViewable = 3; + int GrabFrozen = 4; + + // Event masks + long KeyPressMask = 1L << 0; + long KeyReleaseMask = 1L << 1; + + // Time constants + long CurrentTime = 0L; + + // Constants + int PropModeReplace = 0; + long None = 0L; + long AnyPropertyType = 0L; + long XA_ATOM = 4L; + long XA_WINDOW = 33L; + + /** + * X11 color structure (used for cursor creation) + * Layout: unsigned long pixel (8), unsigned short red/green/blue (2 each), char flags/pad (1 each) + */ + @Structure.FieldOrder({"pixel", "red", "green", "blue", "flags", "pad"}) + class XColor extends Structure { + public long pixel; + public short red, green, blue; + public byte flags, pad; + } + + /** + * X11 Event union structure + */ + @Structure.FieldOrder({"type", "pad"}) + class XEvent extends Structure { + public int type; + public byte[] pad = new byte[192]; // XEvent is 192 bytes + + public XKeyEvent getKeyEvent() { + XKeyEvent keyEvent = new XKeyEvent(getPointer()); + keyEvent.read(); + return keyEvent; + } + } + + /** + * X11 KeyPress/KeyRelease event structure + */ + @Structure.FieldOrder({"type", "serial", "send_event", "display", "window", + "root", "subwindow", "time", "x", "y", "x_root", "y_root", + "state", "keycode", "same_screen"}) + class XKeyEvent extends Structure { + public int type; + public long serial; + public int send_event; + public Pointer display; + public long window; + public long root; + public long subwindow; + public long time; + public int x, y; + public int x_root, y_root; + public int state; + public int keycode; + public int same_screen; + + public XKeyEvent() { + super(); + } + + public XKeyEvent(Pointer p) { + super(p); + } + } + + /** + * Structure for window attributes + */ + @Structure.FieldOrder({"x", "y", "width", "height", "borderWidth", "depth", + "visual", "root", "clazz", "bitGravity", "winGravity", + "backingStore", "backingPlanes", "backingPixel", + "saveUnder", "colormap", "mapInstalled", "mapState", + "allEventMasks", "yourEventMask", "doNotPropagateMask", + "overrideRedirect", "screen"}) + class XWindowAttributes extends Structure { + public int x, y; + public int width, height; + public int borderWidth; + public int depth; + public Pointer visual; + public long root; + public int clazz; + public int bitGravity; + public int winGravity; + public int backingStore; + public long backingPlanes; + public long backingPixel; + public boolean saveUnder; + public long colormap; + public boolean mapInstalled; + public int mapState; + public long allEventMasks; + public long yourEventMask; + public long doNotPropagateMask; + public boolean overrideRedirect; + public Pointer screen; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LibXRandr.java b/src/main/java/mousemaster/platform/linux/LibXRandr.java new file mode 100644 index 00000000..57bfb0ad --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibXRandr.java @@ -0,0 +1,133 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; + +/** + * JNA bindings for XRandR extension (libXrandr.so) + */ +public interface LibXRandr extends Library { + + LibXRandr INSTANCE = Native.load("Xrandr", LibXRandr.class); + + // Screen resources + Pointer XRRGetScreenResources(Pointer display, long window); + void XRRFreeScreenResources(Pointer resources); + + // Monitor information (XRandR 1.5+) + Pointer XRRGetMonitors(Pointer display, long window, boolean getActive, IntByReference nmonitors); + void XRRFreeMonitors(Pointer monitors); + + // CRTC information + Pointer XRRGetCrtcInfo(Pointer display, Pointer resources, long crtc); + void XRRFreeCrtcInfo(Pointer crtcInfo); + + // Output information + Pointer XRRGetOutputInfo(Pointer display, Pointer resources, long output); + void XRRFreeOutputInfo(Pointer outputInfo); + + /** + * Helper class for reading int by reference + */ + class IntByReference extends com.sun.jna.ptr.IntByReference { + public IntByReference() { + super(); + } + public IntByReference(int value) { + super(value); + } + } + + /** + * XRRMonitorInfo structure (XRandR 1.5+) + */ + @Structure.FieldOrder({"name", "primary", "automatic", "noutput", "x", "y", "width", "height", + "mwidth", "mheight", "outputs"}) + class XRRMonitorInfo extends Structure { + public long name; // Atom + public boolean primary; + public boolean automatic; + public int noutput; + public int x, y; + public int width, height; // pixels + public int mwidth, mheight; // millimeters + public Pointer outputs; // RROutput* + + public XRRMonitorInfo() { + super(); + } + + public XRRMonitorInfo(Pointer p) { + super(p); + read(); + } + + public static class ByReference extends XRRMonitorInfo implements Structure.ByReference {} + } + + /** + * XRRScreenResources structure + */ + @Structure.FieldOrder({"timestamp", "configTimestamp", "ncrtc", "crtcs", "noutput", "outputs", + "nmode", "modes"}) + class XRRScreenResources extends Structure { + public long timestamp; + public long configTimestamp; + public int ncrtc; + public Pointer crtcs; // RRCrtc* + public int noutput; + public Pointer outputs; // RROutput* + public int nmode; + public Pointer modes; // XRRModeInfo* + + public static class ByReference extends XRRScreenResources implements Structure.ByReference {} + } + + /** + * XRRCrtcInfo structure + */ + @Structure.FieldOrder({"timestamp", "x", "y", "width", "height", "mode", "rotation", + "noutput", "outputs", "rotations", "npossible", "possible"}) + class XRRCrtcInfo extends Structure { + public long timestamp; + public int x, y; + public int width, height; + public long mode; // RRMode + public short rotation; + public int noutput; + public Pointer outputs; // RROutput* + public short rotations; + public int npossible; + public Pointer possible; // RROutput* + + public static class ByReference extends XRRCrtcInfo implements Structure.ByReference {} + } + + /** + * XRROutputInfo structure + */ + @Structure.FieldOrder({"timestamp", "crtc", "name", "nameLen", "mmWidth", "mmHeight", + "connection", "subpixelOrder", "ncrtc", "crtcs", "nclone", "clones", + "nmode", "npreferred", "modes"}) + class XRROutputInfo extends Structure { + public long timestamp; + public long crtc; // RRCrtc + public Pointer name; // char* + public int nameLen; + public long mmWidth, mmHeight; // millimeters + public byte connection; + public byte subpixelOrder; + public int ncrtc; + public Pointer crtcs; // RRCrtc* + public int nclone; + public Pointer clones; // RROutput* + public int nmode; + public int npreferred; + public Pointer modes; // RRMode* + + public static class ByReference extends XRROutputInfo implements Structure.ByReference {} + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LibXShape.java b/src/main/java/mousemaster/platform/linux/LibXShape.java new file mode 100644 index 00000000..7010ee4f --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibXShape.java @@ -0,0 +1,31 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +/** + * JNA bindings for the X Shape extension (libXext.so), used to make overlay windows + * click-through by giving them an empty input shape. Qt's WA_TransparentForMouseEvents + * attribute does not reliably achieve this for frameless, X11BypassWindowManagerHint + * (override-redirect) windows on every window manager, so the input shape is cleared + * directly at the X11 level instead - the same mechanism compositors/overlay tools use. + */ +public interface LibXShape extends Library { + + LibXShape INSTANCE = Native.load("Xext", LibXShape.class); + + int ShapeInput = 2; + int ShapeSet = 0; + + /** + * Combines rectangles is normally used to set a window's shape; passing a null + * rectangle list with a count of 0 sets the given shape kind to the empty region - + * for ShapeInput, that means the window accepts no mouse/keyboard input anywhere, + * so clicks pass through to whatever is behind it. + */ + void XShapeCombineRectangles(Pointer display, long window, int destKind, + int xOffset, int yOffset, Pointer rectangles, + int rectangleCount, int operation, int ordering); + +} diff --git a/src/main/java/mousemaster/platform/linux/LibXTest.java b/src/main/java/mousemaster/platform/linux/LibXTest.java new file mode 100644 index 00000000..0bad1ef2 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibXTest.java @@ -0,0 +1,13 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +public interface LibXTest extends Library { + LibXTest INSTANCE = Native.load("Xtst", LibXTest.class); + + // Bool is_press: 1 = press, 0 = release + // delay: CurrentTime = 0 + int XTestFakeButtonEvent(Pointer display, int button, int isPress, long delay); +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxActiveAppFinder.java b/src/main/java/mousemaster/platform/linux/LinuxActiveAppFinder.java new file mode 100644 index 00000000..35a984dc --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxActiveAppFinder.java @@ -0,0 +1,22 @@ +package mousemaster.platform.linux; + +import mousemaster.App; +import mousemaster.platform.ActiveAppFinder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stub implementation of ActiveAppFinder for Milestone 1. + * TODO: Implement using XGetInputFocus + _NET_WM_PID + /proc for Milestone 3. + */ +public class LinuxActiveAppFinder implements ActiveAppFinder { + + private static final Logger logger = LoggerFactory.getLogger(LinuxActiveAppFinder.class); + + @Override + public App activeApp() { + // TODO: Get active window via XGetInputFocus and read process name from /proc + return new App("unknown"); + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxClock.java b/src/main/java/mousemaster/platform/linux/LinuxClock.java new file mode 100644 index 00000000..8ff9ab32 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxClock.java @@ -0,0 +1,14 @@ +package mousemaster.platform.linux; + +import mousemaster.Clock; + +import java.time.Instant; + +public class LinuxClock implements Clock { + + @Override + public Instant now() { + return Instant.now(); + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxConsole.java b/src/main/java/mousemaster/platform/linux/LinuxConsole.java new file mode 100644 index 00000000..c5179f16 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxConsole.java @@ -0,0 +1,17 @@ +package mousemaster.platform.linux; + +import mousemaster.platform.Console; + +public class LinuxConsole implements Console { + + @Override + public void show() { + // No-op on Linux - app runs in terminal or as daemon + } + + @Override + public void hide() { + // No-op on Linux - app runs in terminal or as daemon + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxEvdev.java b/src/main/java/mousemaster/platform/linux/LinuxEvdev.java new file mode 100644 index 00000000..f0cc2a94 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxEvdev.java @@ -0,0 +1,217 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLong; +import com.sun.jna.Pointer; +import mousemaster.Key; +import mousemaster.KeyEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Reads raw keyboard input from /dev/input/eventX via evdev. + * Uses EVIOCGRAB for exclusive capture so events don't also reach X11. + * Events are queued and consumed on the main thread via pollEvent(). + */ +public class LinuxEvdev { + + private static final Logger logger = LoggerFactory.getLogger(LinuxEvdev.class); + + // _IOW('E', 0x90, int) = (1<<30)|(0x45<<8)|0x90|(4<<16) + private static final long EVIOCGRAB = 0x40044590L; + // _IOC(_IOC_READ=2, 'E', 0x06, 256) = (2<<30)|(0x45<<8)|0x06|(256<<16) + private static final long EVIOCGNAME_256 = 0x81004506L; + + private static final int INPUT_EVENT_SIZE = 24; // timeval(16) + type(2) + code(2) + value(4) + private static final short EV_KEY = 1; + private static final int KEY_RELEASE = 0; + private static final int KEY_PRESS = 1; + // KEY_REPEAT = 2, ignored + + private static final int O_RDONLY = 0; + + // linux/input-event-codes.h KEY_Q. Devices that report this are real typing + // keyboards; devices that merely have a "kbd" handler but not this bit are things + // like ACPI power/lid/brightness button arrays, which must never be grabbed + // exclusively (doing so silently swallows e.g. the power button). + private static final int KEY_Q = 16; + + interface CLib extends Library { + CLib INSTANCE = Native.load("c", CLib.class); + int open(String path, int flags); + int close(int fd); + NativeLong read(int fd, Pointer buf, NativeLong count); + int ioctl(int fd, NativeLong request, int arg); + int ioctl(int fd, NativeLong request, Pointer buf); + } + + private final ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>(); + private final List openFds = new ArrayList<>(); + private final int uinputFd; + private volatile boolean running = true; + + public LinuxEvdev(int uinputFd) { + this.uinputFd = uinputFd; + List devices = findKeyboardDevices(); + if (devices.isEmpty()) { + logger.warn("No keyboard devices found — run as root or add yourself to the 'input' group"); + return; + } + for (String path : devices) { + openAndGrab(path); + } + } + + private List findKeyboardDevices() { + List result = new ArrayList<>(); + try { + String content = Files.readString(Path.of("/proc/bus/input/devices")); + boolean hasKbd = false; + boolean isVirtual = false; + boolean hasLetterKeys = false; + String eventNode = null; + for (String line : content.lines().toList()) { + if (line.isBlank()) { + if (hasKbd && !isVirtual && hasLetterKeys && eventNode != null) + result.add("/dev/input/" + eventNode); + hasKbd = false; + isVirtual = false; + hasLetterKeys = false; + eventNode = null; + } else if (line.startsWith("S: Sysfs=")) { + // uinput-created devices live under /devices/virtual/ + isVirtual = line.contains("/devices/virtual/"); + } else if (line.startsWith("H: Handlers=")) { + String handlers = line.substring("H: Handlers=".length()); + hasKbd = handlers.contains("kbd"); + for (String token : handlers.split("\\s+")) { + if (token.startsWith("event")) + eventNode = token; + } + } else if (line.startsWith("B: KEY=")) { + hasLetterKeys = hasKeyBit(line.substring("B: KEY=".length()), KEY_Q); + } + } + // Handle last block if file doesn't end with blank line + if (hasKbd && !isVirtual && hasLetterKeys && eventNode != null) + result.add("/dev/input/" + eventNode); + } catch (IOException e) { + logger.error("Cannot read /proc/bus/input/devices: {}", e.getMessage()); + } + logger.info("Found {} keyboard device(s): {}", result.size(), result); + return result; + } + + /** + * The kernel prints the KEY= capability bitmask as space-separated hex words, + * most-significant word first. The last (rightmost) word covers bits 0-63, which is + * all we need to test for a representative letter key like KEY_Q. + */ + private static boolean hasKeyBit(String bitmaskLine, int bit) { + String[] tokens = bitmaskLine.trim().split("\\s+"); + if (tokens.length == 0) + return false; + try { + long lastWord = Long.parseUnsignedLong(tokens[tokens.length - 1], 16); + return ((lastWord >> bit) & 1L) != 0; + } catch (NumberFormatException e) { + return false; + } + } + + private void openAndGrab(String path) { + int fd = CLib.INSTANCE.open(path, O_RDONLY); + if (fd < 0) { + logger.warn("Cannot open {} (errno={}) — skipping", path, Native.getLastError()); + return; + } + + Memory nameBuf = new Memory(256); + nameBuf.clear(); + String name = path; + if (CLib.INSTANCE.ioctl(fd, new NativeLong(EVIOCGNAME_256), nameBuf) >= 0) + name = nameBuf.getString(0); + + if (LibUinput.DEVICE_NAME.equals(name) || LibUinput.KEYBOARD_DEVICE_NAME.equals(name)) { + CLib.INSTANCE.close(fd); + return; + } + + int grabResult = CLib.INSTANCE.ioctl(fd, new NativeLong(EVIOCGRAB), 1); + if (grabResult < 0) { + logger.error("EVIOCGRAB failed for {} '{}' (errno={}). Run as root.", + path, name, Native.getLastError()); + CLib.INSTANCE.close(fd); + return; + } + + logger.info("Grabbed keyboard '{}' ({})", name, path); + openFds.add(fd); + + final String deviceName = name; + Thread t = new Thread(() -> readLoop(fd, deviceName), "evdev-" + deviceName); + t.setDaemon(true); + t.start(); + } + + private void readLoop(int fd, String deviceName) { + Memory buf = new Memory(INPUT_EVENT_SIZE); + while (running) { + buf.clear(); + long n = CLib.INSTANCE.read(fd, buf, new NativeLong(INPUT_EVENT_SIZE)).longValue(); + if (n != INPUT_EVENT_SIZE) { + if (running) + logger.warn("evdev read returned {} for '{}', stopping", n, deviceName); + break; + } + + short type = buf.getShort(16); + short code = buf.getShort(18); + int value = buf.getInt(20); + + if (type == EV_KEY && (value == KEY_PRESS || value == KEY_RELEASE)) { + int keycode = code & 0xFFFF; + Key key = LinuxVirtualKey.fromEvdevCode(keycode); + if (key != null) { + Instant now = Instant.now(); + eventQueue.add(value == KEY_PRESS + ? new KeyEvent.PressKeyEvent(now, key) + : new KeyEvent.ReleaseKeyEvent(now, key)); + } else { + // No Key enum equivalent, so the combo engine can never act on this + // code - regurgitate it as-is instead of silently dropping it, since + // this device is exclusively grabbed and nothing else will ever see + // the event otherwise (e.g. KEY_POWER on an ACPI button-array device). + logger.debug("No Key mapping for evdev code {} on '{}', regurgitating raw", + keycode, deviceName); + LibUinput.writeInputEvent(uinputFd, LibUinput.EV_KEY, keycode, value); + LibUinput.writeInputEvent(uinputFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); + } + } + } + } + + /** Called from the main thread in pumpEvents(). */ + public KeyEvent pollEvent() { + return eventQueue.poll(); + } + + public void destroy() { + running = false; + for (int fd : openFds) { + CLib.INSTANCE.ioctl(fd, new NativeLong(EVIOCGRAB), 0); + CLib.INSTANCE.close(fd); // causes blocking read() to return -1, ending the thread + } + openFds.clear(); + } +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java b/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java new file mode 100644 index 00000000..e99f4c75 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java @@ -0,0 +1,84 @@ +package mousemaster.platform.linux; + +import mousemaster.Key; +import mousemaster.MacroMoveDestination; +import mousemaster.ResolvedKeyMacroMove; +import mousemaster.ResolvedMacroMove; +import mousemaster.platform.KeyboardController; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Implements keyboard passthrough via a uinput virtual keyboard device. + * + * Physical keys are exclusively grabbed by LinuxEvdev (EVIOCGRAB). Keys that the + * combo engine does not consume are re-emitted here so other apps see them normally. + * Macro/regurgitated keys are also injected here. The uinput device is named + * LibUinput.KEYBOARD_DEVICE_NAME so the evdev reader skips it (no feedback loop). + */ +public class LinuxKeyboard implements KeyboardController { + + private static final Logger logger = LoggerFactory.getLogger(LinuxKeyboard.class); + + private final int uinputFd; + + public LinuxKeyboard(int uinputFd) { + this.uinputFd = uinputFd; + } + + public void destroy() { + LibUinput.destroyDevice(uinputFd); + } + + @Override + public void update(double delta) { + } + + @Override + public void reset() { + } + + @Override + public void sendInputMoves(List moves, boolean startRepeat) { + for (ResolvedMacroMove move : moves) { + switch (move) { + case ResolvedKeyMacroMove km -> { + if (km.destination() == MacroMoveDestination.OS) + emitKey(km.key(), km.press() ? 1 : 0); + } + default -> logger.warn("sendInputMoves: unsupported move type {}", move.getClass().getSimpleName()); + } + } + } + + @Override + public void keyPressedNotEaten(Key key) { + emitKey(key, 1); + } + + @Override + public void keyReleasedNotEaten(Key key) { + emitKey(key, 0); + } + + @Override + public void recordEarlyReleaseForQueuedPress(Key key) { + // No send queue on Linux — writes are synchronous, nothing to track + } + + @Override + public void clearEarlyReleaseForQueuedPress(Key key) { + } + + private void emitKey(Key key, int value) { + Integer code = LinuxVirtualKey.toEvdevCode(key); + if (code == null) { + logger.warn("No evdev code for key {}, cannot emit", key); + return; + } + LibUinput.writeInputEvent(uinputFd, LibUinput.EV_KEY, code, value); + LibUinput.writeInputEvent(uinputFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); + } +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxMain.java b/src/main/java/mousemaster/platform/linux/LinuxMain.java new file mode 100644 index 00000000..a6aa6b92 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxMain.java @@ -0,0 +1,78 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Native; +import mousemaster.ApplicationOptions; +import mousemaster.Mousemaster; +import mousemaster.MousemasterApplication; +import mousemaster.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class LinuxMain { + + private static final Logger logger = LoggerFactory.getLogger(LinuxMain.class); + + public static void main(String[] args) throws InterruptedException, IOException { + ApplicationOptions options = ApplicationOptions.parse(args); + MousemasterApplication.setTempDirectory(options.tempDirectory()); + if (options.logLevel() != null) + MousemasterApplication.setLogLevel(options.logLevel()); + if (options.logToFile()) + MousemasterApplication.enableLogToFile(); + String version; + String commitId; + try (InputStream versionInputStream = LinuxMain.class.getClassLoader() + .getResourceAsStream( + "application.properties")) { + Properties versionProp = new Properties(); + versionProp.load(versionInputStream); + version = versionProp.getProperty("version"); + commitId = versionProp.getProperty("commitId"); + } + if (options.showVersion()) { + System.out.println("mousemaster v" + version + " (" + commitId + ")"); + return; + } + if (options.graalvmAgentRun()) { + logger.info("--graalvm-agent-run flag found, exiting in 20s"); + new Thread(() -> { + try { + Thread.sleep(20000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + System.exit(0); + }).start(); + } + Platform platform = createPlatform(options.multipleInstancesAllowed(), + options.keyRegurgitationEnabled(), options.pauseOnError()); + logger.info("mousemaster v" + version + " (" + commitId + ") [Linux]"); + if (platform == null) + return; + try { + Native.setCallbackExceptionHandler((c, e) -> + MousemasterApplication.shutdownAfterException(e, platform, true, + options.pauseOnError())); + new Mousemaster(options.configurationPath(), platform).run(); + } catch (Throwable e) { + MousemasterApplication.shutdownAfterException(e, platform, false, + options.pauseOnError()); + } + } + + private static Platform createPlatform(boolean multipleInstancesAllowed, + boolean keyRegurgitationEnabled, + boolean pauseOnError) { + try { + return new LinuxPlatform(multipleInstancesAllowed, keyRegurgitationEnabled); + } catch (Exception e) { + MousemasterApplication.shutdownAfterException(e, null, false, pauseOnError); + } + return null; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxMouse.java b/src/main/java/mousemaster/platform/linux/LinuxMouse.java new file mode 100644 index 00000000..6a20a84c --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxMouse.java @@ -0,0 +1,20 @@ +package mousemaster.platform.linux; + +import mousemaster.platform.MouseController; + +// Common supertype for X11Mouse and WaylandMouse, so LinuxPlatform's mouse field +// can only ever be one of this platform's own MouseController implementations. +public abstract class LinuxMouse implements MouseController { + + // Overridden by WaylandMouse: XWayland's XQueryPointer-mirrored pointer state doesn't + // reliably reflect motion injected via zwlr_virtual_pointer_v1, so LinuxPlatform's + // position-listener polling needs the last position we ourselves told the compositor + // to move to, instead of (or in preference to) an XQueryPointer round-trip. + public Integer lastSyntheticX() { + return null; + } + + public Integer lastSyntheticY() { + return null; + } +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java new file mode 100644 index 00000000..6e9d44f4 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java @@ -0,0 +1,450 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import io.qt.core.Qt; +import io.qt.gui.QPixmap; +import io.qt.widgets.QApplication; +import io.qt.widgets.QWidget; +import mousemaster.*; +import mousemaster.platform.Overlay; +import mousemaster.platform.Screens; +import mousemaster.qt.QtHintFont; +import mousemaster.qt.ScreenshotWidget; +import mousemaster.qt.TransparentWindow; +import mousemaster.renderer.GridRenderer; +import mousemaster.renderer.HintMeshRenderer; +import mousemaster.renderer.IndicatorRenderer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Linux overlay implementation. Delegates rendering to the same shared, platform-agnostic + * renderer classes Windows uses (GridRenderer, HintMeshRenderer, IndicatorRenderer, + * ScreenshotWidget); this class only handles X11-specific window management (stacking, + * and the one-tick-deferred screenshot capture zoom uses, since Linux has no + * capture-exclusion API like Windows' WDA_EXCLUDEFROMCAPTURE) plus the small glue the + * renderers need (mouse position, active screen, screen enumeration). + */ +public class LinuxOverlay implements Overlay { + + private static final Logger logger = LoggerFactory.getLogger(LinuxOverlay.class); + + /** No XFixesGetCursorImage-based real cursor size lookup yet; assume a fixed size. */ + private static final int DEFAULT_CURSOR_SIZE = 24; + + private final Pointer display; + private final Screens screens; + private final ScreenManager screenManager; + private final LinuxPlatform platform; + private Runnable messagePump; + + private GridRenderer gridRenderer; + /** Owns no QWidget, so it can be created eagerly (no QtJambi native-load ordering). */ + private final HintMeshRenderer hintMeshRenderer; + private IndicatorRenderer indicatorRenderer; + private ScreenshotWidget screenshotWidget; + private Zoom currentZoom; + private boolean waitForZoom; + private boolean zoomAfterHintMeshEndAnimation; + private Zoom afterHintMeshEndAnimationZoom; + + private Rectangle pendingCaptureRect; + private Zoom pendingCaptureZoom; + private boolean pendingCaptureGridWasVisible; + private boolean pendingCaptureHintWasVisible; + + /** Windows awaiting a click-through shape once their real geometry is set (see applyX11OverlayFlags). */ + private final List pendingClickThroughWidgets = new ArrayList<>(); + + public LinuxOverlay(Pointer display, Screens screens, LinuxPlatform platform) { + this.display = display; + this.screens = screens; + this.screenManager = new ScreenManager(screens); + this.platform = platform; + hintMeshRenderer = new HintMeshRenderer(this::createStyledHintMeshWindow, + this::hintMeshEndAnimationEndedCallback); + } + + /** The window factory the renderer uses: a styled, transparent, click-through window. */ + private TransparentWindow createStyledHintMeshWindow() { + TransparentWindow window = new TransparentWindow(); + applyX11OverlayFlags(window); + return window; + } + + /** Runs when the hint container end-animation finishes: hides the hint mesh, then + * applies any zoom that was deferred until the animation finished. */ + private void hintMeshEndAnimationEndedCallback() { + hideHintMesh(); + if (zoomAfterHintMeshEndAnimation) { + zoomAfterHintMeshEndAnimation = false; + Zoom zoom = afterHintMeshEndAnimationZoom; + afterHintMeshEndAnimationZoom = null; + setZoom(zoom); + } + } + + @Override + public void update(double delta) { + hintMeshRenderer.runPendingWork(); + if (pendingCaptureRect == null) + return; + // The hide() calls in requestScreenshotCapture() only ran on a previous tick; + // QtManager.processEvents() at the top of *this* tick (see Mousemaster's main + // loop - platform.update() runs before zoomManager.update(), so a capture + // requested during zoom's update is only fulfilled here, one tick later) has + // now flushed them, so the windows are actually gone from the screen and it's + // safe to grab. Capturing in the same tick the hide() was issued would race + // X11 and often capture the not-yet-unmapped windows. + QPixmap pixmap = QApplication.primaryScreen() + .grabWindow(0, pendingCaptureRect.x(), + pendingCaptureRect.y(), + pendingCaptureRect.width(), + pendingCaptureRect.height()); + if (pendingCaptureGridWasVisible) + gridRenderer.widget().show(); + if (pendingCaptureHintWasVisible) + for (TransparentWindow window : hintMeshRenderer.windows()) + window.show(); + if (screenshotWidget == null) + createScreenshotWindow(); + screenshotWidget.move(pendingCaptureRect.x(), pendingCaptureRect.y()); + screenshotWidget.resize(pendingCaptureRect.width(), pendingCaptureRect.height()); + screenshotWidget.setScreenshot(pixmap, pendingCaptureRect); + screenshotWidget.setZoom(pendingCaptureZoom); + currentZoom = pendingCaptureZoom; + screenshotWidget.show(); + screenshotWidget.repaint(); + setTopmost(); + pendingCaptureRect = null; + pendingCaptureZoom = null; + } + + @Override + public void flushCache() { + hintMeshRenderer.flushCache(); + } + + @Override + public void setTopmost() { + applyPendingClickThrough(); + // Mirrors WindowsOverlay's z-order (topmost first: grid, then hint windows, + // then indicator, then the zoom/screenshot backdrop at the back). Qt's raise() + // brings a top-level window to the front of the stack, so raise in the reverse + // order here - whichever should end up topmost is raised last. + if (screenshotWidget != null) + screenshotWidget.raise(); + if (indicatorRenderer != null && indicatorRenderer.showing()) + indicatorRenderer.window().raise(); + for (TransparentWindow window : hintMeshRenderer.windows()) + window.raise(); + if (gridRenderer != null) + gridRenderer.widget().raise(); + } + + @Override + public void setMessagePump(Runnable pump) { + this.messagePump = pump; + hintMeshRenderer.setMessagePump(pump); + } + + /** + * Pre-warms the font engine with all hint fonts from the configuration, shifting + * the cost of first-use font metrics computation away from the first hint render. + */ + @Override + public void preWarmFontStyles(Set configs) { + QtHintFont.preWarm(configs); + } + + @Override + public void preWarmHintMeshWindows() { + hintMeshRenderer.preWarmHintMeshWindows(screens.findScreens()); + } + + @Override + public Rectangle activeWindowRectangle(double widthPct, double heightPct, + int topInset, int bottomInset, + int leftInset, int rightInset) { + // TODO: Get active window rectangle using X11 XGetInputFocus + XGetWindowAttributes + logger.debug("activeWindowRectangle() called - returning dummy rectangle"); + return new Rectangle(0, 0, 1920, 1080); + } + + @Override + public void setIndicator(Indicator indicator, boolean fadeAnimationEnabled, + Duration fadeAnimationDuration, boolean allowFade) { + Objects.requireNonNull(indicator); + if (indicatorRenderer == null) { + indicatorRenderer = new IndicatorRenderer(); + applyX11OverlayFlags(indicatorRenderer.window()); + } + indicatorRenderer.setIndicator(indicator, fadeAnimationEnabled, + fadeAnimationDuration, allowFade, mouseRectangle(), cursorVisualCenter(), + activeScreen(), currentZoom); + setTopmost(); + } + + @Override + public void hideIndicator(boolean allowFade) { + if (indicatorRenderer != null) + indicatorRenderer.hide(allowFade); + } + + /** Repositions the visible indicator when the mouse moves. Windows does this via its + * low-level hook calling WindowsOverlay.mouseMoved(); Linux has no such hook, so + * LinuxPlatform's XQueryPointer-based polling calls this directly instead. */ + void mouseMoved(int x, int y) { + if (indicatorRenderer == null || !indicatorRenderer.showing()) + return; + // During zoom, currentZoom may still reflect the previous frame's center; the + // active zoom manager corrects it via updateScreenshotZoom before the next + // repaint, so skip here to avoid a stale-center mispositioning (mirrors + // WindowsOverlay.mouseMoved's same early-return during an active zoom). + if (currentZoom != null) + return; + indicatorRenderer.reposition(new Rectangle(x, y, DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SIZE), new Point(0, 0), + screenManager.nearestScreenContaining(x, y), null); + } + + private int mouseX() { + Integer x = platform.lastMouseX(); + return x != null ? x : 0; + } + + private int mouseY() { + Integer y = platform.lastMouseY(); + return y != null ? y : 0; + } + + private Rectangle mouseRectangle() { + return new Rectangle(mouseX(), mouseY(), DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SIZE); + } + + private Point cursorVisualCenter() { + // Linux has no per-cursor-bitmap hotspot lookup (unlike WindowsMouseController's + // GetIconInfo-based computeCursorVisualCenter); mirror Windows' own + // fallback-when-lookup-fails value of (0, 0) rather than the cursor's actual + // visual center relative to its hotspot. + return new Point(0, 0); + } + + private Screen activeScreen() { + return screenManager.nearestScreenContaining(mouseX(), mouseY()); + } + + @Override + public void setGrid(Grid grid) { + Objects.requireNonNull(grid); + boolean firstCreation = gridRenderer == null; + if (firstCreation) { + gridRenderer = new GridRenderer(); + applyX11OverlayFlags(gridRenderer.widget()); + } + gridRenderer.setGrid(grid, virtualDesktopBounds(), + (int) Math.round(grid.lineThickness())); + setTopmost(); + } + + @Override + public void hideGrid() { + if (gridRenderer != null) + gridRenderer.hide(); + } + + @Override + public void setHintMesh(HintMesh hintMesh, Zoom zoom) { + setHintMesh(hintMesh, zoom, false); + } + + @Override + public void setHintMesh(HintMesh hintMesh, Zoom zoom, boolean hintMatch) { + boolean nonMatchShown = hintMeshRenderer.setHintMesh(hintMesh, zoom, hintMatch, + screens.findScreens()); + if (nonMatchShown && zoomAfterHintMeshEndAnimation) { + zoomAfterHintMeshEndAnimation = false; + Zoom deferredZoom = afterHintMeshEndAnimationZoom; + afterHintMeshEndAnimationZoom = null; + setZoom(deferredZoom); + } + setTopmost(); + } + + @Override + public void hideHintMesh() { + hintMeshRenderer.hideHintMesh(); + } + + @Override + public void animateHintMatch(Hint hint) { + hintMeshRenderer.animateHintMatch(hint, screens.findScreens()); + } + + @Override + public void setZoom(Zoom zoom) { + if (currentZoom != null && currentZoom.equals(zoom)) + return; + if (hintMeshRenderer.isHintMeshEndAnimation()) { + if (!zoomAfterHintMeshEndAnimation) { + zoomAfterHintMeshEndAnimation = true; + afterHintMeshEndAnimationZoom = zoom; + return; + } + else { + // We skip the enqueued zoom. + zoomAfterHintMeshEndAnimation = false; + afterHintMeshEndAnimationZoom = null; + } + } + if (zoom == null) { + cancelPendingCapture(); + currentZoom = null; + if (screenshotWidget != null) { + screenshotWidget.setZoom(null); + screenshotWidget.hide(); + } + return; + } + requestScreenshotCapture(zoom.screenRectangle(), zoom); + } + + @Override + public void startScreenshotZoomAnimation(Rectangle screenRect, Zoom beginZoom) { + requestScreenshotCapture(screenRect, beginZoom); + } + + @Override + public void updateScreenshotZoom(Zoom zoom) { + currentZoom = zoom; + if (screenshotWidget == null) + return; + screenshotWidget.setZoom(zoom); + screenshotWidget.repaint(); + mouseMoved(mouseX(), mouseY()); + setTopmost(); + } + + @Override + public void endScreenshotZoomAnimation(Zoom finalZoom) { + if (screenshotWidget == null) + return; + if (finalZoom == null) { + currentZoom = null; + screenshotWidget.setZoom(null); + screenshotWidget.hide(); + return; + } + currentZoom = finalZoom; + screenshotWidget.setZoom(finalZoom); + screenshotWidget.repaint(); + setTopmost(); + } + + /** + * Hides our own overlay windows (which must not appear in the captured backdrop - + * Linux has no capture-exclusion API like Windows' WDA_EXCLUDEFROMCAPTURE/ + * MagSetWindowFilterList) and records the capture to perform. The actual + * grabWindow() call happens on the next tick's update(), once the hide has + * actually been flushed - see the comment there. + */ + private void requestScreenshotCapture(Rectangle rect, Zoom zoom) { + if (pendingCaptureRect == null) { + pendingCaptureGridWasVisible = gridRenderer != null && gridRenderer.showing(); + pendingCaptureHintWasVisible = hintMeshRenderer.showing(); + if (pendingCaptureGridWasVisible) + gridRenderer.widget().hide(); + if (pendingCaptureHintWasVisible) + for (TransparentWindow window : hintMeshRenderer.windows()) + window.hide(); + if (screenshotWidget != null) + screenshotWidget.hide(); + } + pendingCaptureRect = rect; + pendingCaptureZoom = zoom; + } + + /** Aborts a capture requested but not yet fulfilled, restoring window visibility. */ + private void cancelPendingCapture() { + if (pendingCaptureRect == null) + return; + if (pendingCaptureGridWasVisible) + gridRenderer.widget().show(); + if (pendingCaptureHintWasVisible) + for (TransparentWindow window : hintMeshRenderer.windows()) + window.show(); + pendingCaptureRect = null; + pendingCaptureZoom = null; + } + + private void createScreenshotWindow() { + screenshotWidget = new ScreenshotWidget(); + applyX11OverlayFlags(screenshotWidget); + } + + /** + * Applies the X11-specific window flags/attributes every overlay window needs: + * topmost, bypass-the-window-manager, and click-through. Called on all four window + * types (indicator, hint-mesh, grid, screenshot) - GridRenderer's internal widget + * and ScreenshotWidget are shared with Windows and only set FramelessWindowHint + * themselves (Windows applies its own native WS_EX_* equivalents after construction + * instead), and even TransparentWindow-based windows need the real X11 shape call + * below, not just the Qt-level attribute set in its constructor. + */ + private void applyX11OverlayFlags(QWidget widget) { + widget.setWindowFlags(Qt.WindowType.FramelessWindowHint, + Qt.WindowType.X11BypassWindowManagerHint, + Qt.WindowType.WindowStaysOnTopHint); + widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents); + // Deferred to setTopmost(): makeClickThrough() forces the native X11 window to + // be created via winId(), and at this point the widget still has its default + // pre-resize size. Applying it here would create the window at the wrong size. + pendingClickThroughWidgets.add(widget); + } + + private void applyPendingClickThrough() { + for (QWidget widget : pendingClickThroughWidgets) + makeClickThrough(widget); + pendingClickThroughWidgets.clear(); + } + + /** + * Clears the window's X11 input shape so every click passes through to whatever is + * behind it - WA_TransparentForMouseEvents alone isn't enough for frameless, + * override-redirect windows on every window manager. + */ + private void makeClickThrough(QWidget widget) { + LibXShape.INSTANCE.XShapeCombineRectangles(display, widget.winId(), + LibXShape.ShapeInput, 0, 0, Pointer.NULL, 0, LibXShape.ShapeSet, 0); + } + + private Rectangle virtualDesktopBounds() { + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE; + for (Screen screen : screens.findScreens()) { + Rectangle r = screen.rectangle(); + minX = Math.min(minX, r.x()); + minY = Math.min(minY, r.y()); + maxX = Math.max(maxX, r.x() + r.width()); + maxY = Math.max(maxY, r.y() + r.height()); + } + return new Rectangle(minX, minY, maxX - minX, maxY - minY); + } + + @Override + public boolean waitForZoomBeforeRepainting() { + return waitForZoom; + } + + @Override + public void setWaitForZoomBeforeRepainting(boolean value) { + this.waitForZoom = value; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java new file mode 100644 index 00000000..f9a1f2e1 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -0,0 +1,277 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.LongByReference; +import mousemaster.*; +import mousemaster.platform.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; + +/** + * Linux platform implementation. Wires together evdev input capture, uinput passthrough, + * X11 mouse control, and Qt overlay rendering. + */ +public class LinuxPlatform implements Platform { + + private static final Logger logger = LoggerFactory.getLogger(LinuxPlatform.class); + + private final Pointer display; + private final LinuxClock clock; + private final LinuxKeyboard keyboard; + private final LinuxMouse mouse; + private final LinuxScreens screens; + private final LinuxOverlay overlay; + private final LinuxUiAutomation uiAutomation; + private final LinuxActiveAppFinder activeAppFinder; + private final LinuxConsole console; + private final KeyRegurgitator keyRegurgitator; + + private final LinuxEvdev evdev; + private KeyboardLayout activeKeyboardLayout; + private MouseManager mouseManager; + private KeyboardManager keyboardManager; + private List mousePositionListeners; + private ModeMap modeMap; + private long rootWindow; + private final boolean isWayland; + private Integer lastMouseX; + private Integer lastMouseY; + private double timeSinceLastSetTopmost; + private static final double SET_TOPMOST_INTERVAL_SECONDS = 0.2; + + public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationEnabled) { + logger.info("Initializing LinuxPlatform"); + + // Check if running under Wayland + String sessionType = System.getenv("XDG_SESSION_TYPE"); + String waylandDisplay = System.getenv("WAYLAND_DISPLAY"); + isWayland = "wayland".equals(sessionType) || waylandDisplay != null; + + // Open X11 display connection (works even under XWayland for rendering) + display = LibX11.INSTANCE.XOpenDisplay(null); + if (display == null) { + throw new IllegalStateException("Unable to open X11 display - is DISPLAY environment variable set?"); + } + logger.info("X11 display opened successfully (Wayland={})", isWayland); + + // Initialize all platform components + clock = new LinuxClock(); + int uinputKeyboardFd = LibUinput.createKeyboardDevice(); + keyboard = new LinuxKeyboard(uinputKeyboardFd); + screens = new LinuxScreens(display); + overlay = new LinuxOverlay(display, screens, this); + uiAutomation = new LinuxUiAutomation(); + activeAppFinder = new LinuxActiveAppFinder(); + console = new LinuxConsole(); + keyRegurgitator = new KeyRegurgitator(keyboard); + + // rootWindow must be obtained before creating X11Mouse (mouse needs it for XWarpPointer) + rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + logger.info("Root window handle: {}", rootWindow); + + mouse = isWayland ? new WaylandMouse(screens) : new X11Mouse(display, rootWindow); + evdev = new LinuxEvdev(uinputKeyboardFd); + + logger.info("LinuxPlatform initialized successfully"); + + // Default to US QWERTY layout until XKB detection is implemented (Milestone 3) + activeKeyboardLayout = KeyboardLayout.keyboardLayoutByIdentifier.get("00000409"); + if (activeKeyboardLayout == null && !KeyboardLayout.keyboardLayoutByIdentifier.isEmpty()) { + activeKeyboardLayout = KeyboardLayout.keyboardLayoutByIdentifier.values().iterator().next(); + logger.warn("US layout not found, using fallback: {}", activeKeyboardLayout.identifier()); + } + } + + @Override + public void update(double delta) { + pumpEvents(); + overlay.update(delta); + // Periodically re-assert overlay window stacking, in case a tiling WM (e.g. + // Hyprland) or another always-on-top app didn't fully respect + // WindowStaysOnTopHint - mirrors WindowsPlatform's own periodic setTopmost(). + timeSinceLastSetTopmost += delta; + if (timeSinceLastSetTopmost >= SET_TOPMOST_INTERVAL_SECONDS) { + timeSinceLastSetTopmost = 0; + overlay.setTopmost(); + } + } + + @Override + public void pumpEvents() { + KeyEvent event = evdev.pollEvent(); + while (event != null) { + logger.debug("evdev: {}", event); + if (keyboardManager != null) + handleKeyEvent(event); + event = evdev.pollEvent(); + } + } + + /** + * Unlike Windows' low-level hook return value, Linux has no way to suppress a key + * after grabbing the device exclusively (EVIOCGRAB) — regurgitates must be + * explicitly re-injected here or they're silently dropped. + */ + private void handleKeyEvent(KeyEvent event) { + KeyboardManager.EatAndRegurgitates eatAndRegurgitates = keyboardManager.keyEvent(event); + for (KeyboardManager.Regurgitate regurgitate : eatAndRegurgitates.regurgitates()) { + keyRegurgitator.regurgitate(regurgitate, !regurgitate.alsoRelease()); + } + } + + @Override + public void sleep() throws InterruptedException { + pumpEvents(); + notifyMousePositionListenersIfMoved(); + Thread.sleep(10); + pumpEvents(); + } + + /** + * Windows notifies MousePositionListener (e.g. MouseManager, to detect a smooth + * jump reaching its destination) via its low-level mouse hook. X11 has no such + * hook, so the position is polled instead. + */ + private void notifyMousePositionListenersIfMoved() { + int x; + int y; + // XWayland's XQueryPointer-mirrored pointer state doesn't reliably reflect motion + // injected via zwlr_virtual_pointer_v1 (WaylandMouse), so prefer the position we + // ourselves last told the compositor to move to over an XQueryPointer round-trip. + Integer syntheticX = mouse.lastSyntheticX(); + Integer syntheticY = mouse.lastSyntheticY(); + if (syntheticX != null && syntheticY != null) { + x = syntheticX; + y = syntheticY; + } else { + LongByReference root = new LongByReference(); + LongByReference child = new LongByReference(); + IntByReference rootX = new IntByReference(); + IntByReference rootY = new IntByReference(); + IntByReference winX = new IntByReference(); + IntByReference winY = new IntByReference(); + IntByReference mask = new IntByReference(); + boolean sameScreen = LibX11.INSTANCE.XQueryPointer(display, rootWindow, root, child, + rootX, rootY, winX, winY, mask); + if (!sameScreen) + return; + x = rootX.getValue(); + y = rootY.getValue(); + } + boolean positionUnchanged = lastMouseX != null && lastMouseX == x && + lastMouseY != null && lastMouseY == y; + if (positionUnchanged) + return; + lastMouseX = x; + lastMouseY = y; + for (MousePositionListener listener : mousePositionListeners) { + listener.mouseMoved(x, y); + } + // Windows notifies its overlay's indicator via the low-level mouse hook + // (WindowsOverlay.mouseMoved()); X11 has no such hook, so pass the polled + // position along the same way as for the listeners above. + overlay.mouseMoved(x, y); + } + + /** The most recently polled mouse position, or null before the first poll. */ + Integer lastMouseX() { + return lastMouseX; + } + + Integer lastMouseY() { + return lastMouseY; + } + + @Override + public void reset(MouseManager mouseManager, KeyboardManager keyboardManager, + ModeMap modeMap, List mousePositionListeners, + KeyboardLayout activeKeyboardLayout) { + this.mouseManager = mouseManager; + this.keyboardManager = keyboardManager; + this.mousePositionListeners = mousePositionListeners; + this.modeMap = modeMap; + this.activeKeyboardLayout = activeKeyboardLayout; + overlay.setMessagePump(this::pumpEvents); + lastMouseX = null; + lastMouseY = null; + notifyMousePositionListenersIfMoved(); + } + + @Override + public void shutdown() { + logger.info("Shutting down LinuxPlatform"); + + evdev.destroy(); + keyboard.destroy(); + mouse.destroy(); + + if (display != null) { + LibX11.INSTANCE.XCloseDisplay(display); + logger.info("X11 display closed"); + } + } + + @Override + public KeyRegurgitator keyRegurgitator() { + return keyRegurgitator; + } + + @Override + public Clock clock() { + return clock; + } + + @Override + public KeyboardLayout activeKeyboardLayout() { + return activeKeyboardLayout; + } + + @Override + public KeyboardController keyboard() { + return keyboard; + } + + @Override + public mousemaster.platform.MouseController mouse() { + return mouse; + } + + @Override + public Screens screens() { + return screens; + } + + @Override + public Overlay overlay() { + return overlay; + } + + @Override + public UiAutomation uiAutomation() { + return uiAutomation; + } + + @Override + public ActiveAppFinder activeAppFinder() { + return activeAppFinder; + } + + @Override + public Console console() { + return console; + } + + @Override + public void modeChanged(Mode newMode) { + logger.debug("Mode changed to: {}", newMode.name()); + } + + @Override + public void modeTimedOut() { + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxScreens.java b/src/main/java/mousemaster/platform/linux/LinuxScreens.java new file mode 100644 index 00000000..95b4b4b7 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxScreens.java @@ -0,0 +1,96 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import mousemaster.Rectangle; +import mousemaster.Screen; +import mousemaster.platform.Screens; +import mousemaster.platform.linux.LibXRandr.IntByReference; +import mousemaster.platform.linux.LibXRandr.XRRMonitorInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.Set; + +public class LinuxScreens implements Screens { + + private static final Logger logger = LoggerFactory.getLogger(LinuxScreens.class); + private final Pointer display; + + public LinuxScreens(Pointer display) { + this.display = display; + } + + @Override + public Set findScreens() { + Set screens = new HashSet<>(); + + long rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + IntByReference nmonitorsRef = new IntByReference(0); + + // This is a synchronous X server round-trip called on the hot path (every + // Mousemaster.run() tick, uncached). If the X server ever stalls, this is the + // most likely place the whole single-threaded main loop would hang - log + // loudly (WARN, so it shows regardless of configured log level) if it ever + // takes unusually long, to pin down a future freeze instead of just seeing a + // gap in the log. + long callStartNanos = System.nanoTime(); + Pointer monitorsPtr = LibXRandr.INSTANCE.XRRGetMonitors(display, rootWindow, true, nmonitorsRef); + double callDurationSeconds = (System.nanoTime() - callStartNanos) / 1_000_000_000.0; + if (callDurationSeconds > 1) + logger.warn("XRRGetMonitors took {}s - possible X server stall", callDurationSeconds); + + if (monitorsPtr == null) { + logger.warn("XRRGetMonitors returned null - no monitors detected"); + return screens; + } + + int nmonitors = nmonitorsRef.getValue(); + logger.trace("Detected {} monitor(s)", nmonitors); + + XRRMonitorInfo monitorInfo = new XRRMonitorInfo(monitorsPtr); + XRRMonitorInfo[] monitorArray = (XRRMonitorInfo[]) monitorInfo.toArray(nmonitors); + + for (int i = 0; i < nmonitors; i++) { + XRRMonitorInfo monitor = monitorArray[i]; + + Rectangle rectangle = new Rectangle( + monitor.x, + monitor.y, + monitor.width, + monitor.height + ); + + int dpi = calculateDpi(monitor.width, monitor.mwidth); + double scale = dpi / 96.0; + + logger.trace("Monitor {}: {}x{} at ({},{}), {}mm x {}mm, DPI={}, scale={}", + i, monitor.width, monitor.height, monitor.x, monitor.y, + monitor.mwidth, monitor.mheight, dpi, scale); + + screens.add(new Screen(rectangle, dpi, scale)); + } + + LibXRandr.INSTANCE.XRRFreeMonitors(monitorsPtr); + + return screens; + } + + private int calculateDpi(int pixels, int millimeters) { + if (millimeters <= 0) { + logger.warn("Invalid physical dimension ({}mm), using default 96 DPI", millimeters); + return 96; + } + + double inches = millimeters / 25.4; + int dpi = (int) Math.round(pixels / inches); + + if (dpi < 50 || dpi > 500) { + logger.warn("Calculated DPI {} is out of reasonable range, using 96 DPI", dpi); + return 96; + } + + return dpi; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxUiAutomation.java b/src/main/java/mousemaster/platform/linux/LinuxUiAutomation.java new file mode 100644 index 00000000..e2c2034b --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxUiAutomation.java @@ -0,0 +1,27 @@ +package mousemaster.platform.linux; + +import mousemaster.platform.UiAutomation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +/** + * Stub implementation of UiAutomation for Milestone 1. + * TODO: Implement using AT-SPI2 via D-Bus for Milestone 4. + */ +public class LinuxUiAutomation implements UiAutomation { + + private static final Logger logger = LoggerFactory.getLogger(LinuxUiAutomation.class); + + @Override + public Future> startFindInteractiveUiElements() { + // TODO: Query AT-SPI2 for accessible UI elements via D-Bus + logger.debug("startFindInteractiveUiElements() called - returning empty list"); + return CompletableFuture.completedFuture(Collections.emptyList()); + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxVirtualKey.java b/src/main/java/mousemaster/platform/linux/LinuxVirtualKey.java new file mode 100644 index 00000000..2ffef197 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxVirtualKey.java @@ -0,0 +1,242 @@ +package mousemaster.platform.linux; + +import mousemaster.Key; + +import java.util.HashMap; +import java.util.Map; + +public class LinuxVirtualKey { + + private static final Map KEYSYM_TO_KEY = Map.ofEntries( + // Modifiers + Map.entry("Alt_L", Key.leftalt), + Map.entry("Alt_R", Key.rightalt), + Map.entry("Shift_L", Key.leftshift), + Map.entry("Shift_R", Key.rightshift), + Map.entry("Control_L", Key.leftctrl), + Map.entry("Control_R", Key.rightctrl), + Map.entry("Super_L", Key.leftwin), + Map.entry("Super_R", Key.rightwin), + // Navigation / editing + Map.entry("Return", Key.enter), + Map.entry("Escape", Key.esc), + Map.entry("BackSpace", Key.backspace), + Map.entry("Tab", Key.tab), + Map.entry("space", Key.space), + Map.entry("Delete", Key.del), + Map.entry("Insert", Key.insert), + Map.entry("Home", Key.home), + Map.entry("End", Key.end), + Map.entry("Prior", Key.pageup), + Map.entry("Next", Key.pagedown), + Map.entry("Up", Key.uparrow), + Map.entry("Down", Key.downarrow), + Map.entry("Left", Key.leftarrow), + Map.entry("Right", Key.rightarrow), + // Locks / misc + Map.entry("Caps_Lock", Key.capslock), + Map.entry("Num_Lock", Key.numlock), + Map.entry("Scroll_Lock", Key.scrolllock), + Map.entry("Pause", Key.pause), + Map.entry("Print", Key.printscreen), + Map.entry("Menu", Key.menu), + // Function keys + Map.entry("F1", Key.f1), + Map.entry("F2", Key.f2), + Map.entry("F3", Key.f3), + Map.entry("F4", Key.f4), + Map.entry("F5", Key.f5), + Map.entry("F6", Key.f6), + Map.entry("F7", Key.f7), + Map.entry("F8", Key.f8), + Map.entry("F9", Key.f9), + Map.entry("F10", Key.f10), + Map.entry("F11", Key.f11), + Map.entry("F12", Key.f12), + Map.entry("F13", Key.f13), + Map.entry("F14", Key.f14), + Map.entry("F15", Key.f15), + Map.entry("F16", Key.f16), + Map.entry("F17", Key.f17), + Map.entry("F18", Key.f18), + Map.entry("F19", Key.f19), + Map.entry("F20", Key.f20), + Map.entry("F21", Key.f21), + Map.entry("F22", Key.f22), + Map.entry("F23", Key.f23), + Map.entry("F24", Key.f24), + // Numpad + Map.entry("KP_0", Key.numpad0), + Map.entry("KP_1", Key.numpad1), + Map.entry("KP_2", Key.numpad2), + Map.entry("KP_3", Key.numpad3), + Map.entry("KP_4", Key.numpad4), + Map.entry("KP_5", Key.numpad5), + Map.entry("KP_6", Key.numpad6), + Map.entry("KP_7", Key.numpad7), + Map.entry("KP_8", Key.numpad8), + Map.entry("KP_9", Key.numpad9), + Map.entry("KP_Multiply", Key.numpadmultiply), + Map.entry("KP_Add", Key.numpadadd), + Map.entry("KP_Subtract", Key.numpadsubtract), + Map.entry("KP_Decimal", Key.numpaddecimal), + Map.entry("KP_Divide", Key.numpaddivide), + Map.entry("KP_Enter", Key.enter), + // Punctuation with static Key constants + Map.entry("plus", Key.plus), + Map.entry("minus", Key.minus), + Map.entry("underscore", Key.underscore), + Map.entry("bar", Key.pipe), + Map.entry("asciicircum", Key.caret), + Map.entry("braceleft", Key.leftcurlybrace), + Map.entry("braceright", Key.rightcurlybrace), + Map.entry("backslash", Key.backslash), + Map.entry("numbersign", Key.hash) + ); + + // Evdev keycode → Key (assumes QWERTY physical layout for character keys) + private static final Map EVDEV_TO_KEY = new HashMap<>(); + // Key → evdev keycode (reverse of EVDEV_TO_KEY; built after EVDEV_TO_KEY is populated) + private static final Map KEY_TO_EVDEV = new HashMap<>(); + + static { + // Special / static keys + EVDEV_TO_KEY.put(1, Key.esc); + EVDEV_TO_KEY.put(14, Key.backspace); + EVDEV_TO_KEY.put(15, Key.tab); + EVDEV_TO_KEY.put(28, Key.enter); + EVDEV_TO_KEY.put(29, Key.leftctrl); + EVDEV_TO_KEY.put(42, Key.leftshift); + EVDEV_TO_KEY.put(54, Key.rightshift); + EVDEV_TO_KEY.put(56, Key.leftalt); + EVDEV_TO_KEY.put(57, Key.space); + EVDEV_TO_KEY.put(58, Key.capslock); + EVDEV_TO_KEY.put(59, Key.f1); + EVDEV_TO_KEY.put(60, Key.f2); + EVDEV_TO_KEY.put(61, Key.f3); + EVDEV_TO_KEY.put(62, Key.f4); + EVDEV_TO_KEY.put(63, Key.f5); + EVDEV_TO_KEY.put(64, Key.f6); + EVDEV_TO_KEY.put(65, Key.f7); + EVDEV_TO_KEY.put(66, Key.f8); + EVDEV_TO_KEY.put(67, Key.f9); + EVDEV_TO_KEY.put(68, Key.f10); + EVDEV_TO_KEY.put(69, Key.numlock); + EVDEV_TO_KEY.put(70, Key.scrolllock); + EVDEV_TO_KEY.put(71, Key.numpad7); + EVDEV_TO_KEY.put(72, Key.numpad8); + EVDEV_TO_KEY.put(73, Key.numpad9); + EVDEV_TO_KEY.put(74, Key.numpadsubtract); + EVDEV_TO_KEY.put(75, Key.numpad4); + EVDEV_TO_KEY.put(76, Key.numpad5); + EVDEV_TO_KEY.put(77, Key.numpad6); + EVDEV_TO_KEY.put(78, Key.numpadadd); + EVDEV_TO_KEY.put(79, Key.numpad1); + EVDEV_TO_KEY.put(80, Key.numpad2); + EVDEV_TO_KEY.put(81, Key.numpad3); + EVDEV_TO_KEY.put(82, Key.numpad0); + EVDEV_TO_KEY.put(83, Key.numpaddecimal); + EVDEV_TO_KEY.put(87, Key.f11); + EVDEV_TO_KEY.put(88, Key.f12); + EVDEV_TO_KEY.put(96, Key.enter); // KP_ENTER + EVDEV_TO_KEY.put(97, Key.rightctrl); + EVDEV_TO_KEY.put(98, Key.numpaddivide); + EVDEV_TO_KEY.put(99, Key.printscreen); + EVDEV_TO_KEY.put(100, Key.rightalt); + EVDEV_TO_KEY.put(102, Key.home); + EVDEV_TO_KEY.put(103, Key.uparrow); + EVDEV_TO_KEY.put(104, Key.pageup); + EVDEV_TO_KEY.put(105, Key.leftarrow); + EVDEV_TO_KEY.put(106, Key.rightarrow); + EVDEV_TO_KEY.put(107, Key.end); + EVDEV_TO_KEY.put(108, Key.downarrow); + EVDEV_TO_KEY.put(109, Key.pagedown); + EVDEV_TO_KEY.put(110, Key.insert); + EVDEV_TO_KEY.put(111, Key.del); + EVDEV_TO_KEY.put(119, Key.pause); + EVDEV_TO_KEY.put(125, Key.leftwin); + EVDEV_TO_KEY.put(126, Key.rightwin); + EVDEV_TO_KEY.put(127, Key.menu); + EVDEV_TO_KEY.put(183, Key.f13); + EVDEV_TO_KEY.put(184, Key.f14); + EVDEV_TO_KEY.put(185, Key.f15); + EVDEV_TO_KEY.put(186, Key.f16); + EVDEV_TO_KEY.put(187, Key.f17); + EVDEV_TO_KEY.put(188, Key.f18); + EVDEV_TO_KEY.put(189, Key.f19); + EVDEV_TO_KEY.put(190, Key.f20); + EVDEV_TO_KEY.put(191, Key.f21); + EVDEV_TO_KEY.put(192, Key.f22); + EVDEV_TO_KEY.put(193, Key.f23); + EVDEV_TO_KEY.put(194, Key.f24); + EVDEV_TO_KEY.put(55, Key.numpadmultiply); + + // Character keys — physical QWERTY positions mapped to unshifted characters + int[] digitCodes = {11, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 0-9 + for (int i = 0; i < digitCodes.length; i++) + EVDEV_TO_KEY.put(digitCodes[i], Key.ofCharacter(String.valueOf((char)('0' + i)))); + + String qwerty = "qwertyuiopasdfghjklzxcvbnm"; + int[] letterCodes = { + 16,17,18,19,20,21,22,23,24,25, // q w e r t y u i o p + 30,31,32,33,34,35,36,37,38, // a s d f g h j k l + 44,45,46,47,48,49,50 // z x c v b n m + }; + for (int i = 0; i < letterCodes.length; i++) + EVDEV_TO_KEY.put(letterCodes[i], Key.ofCharacter(String.valueOf(qwerty.charAt(i)))); + + // Punctuation + EVDEV_TO_KEY.put(12, Key.minus); + EVDEV_TO_KEY.put(13, Key.ofCharacter("=")); + EVDEV_TO_KEY.put(26, Key.ofCharacter("[")); + EVDEV_TO_KEY.put(27, Key.ofCharacter("]")); + EVDEV_TO_KEY.put(39, Key.ofCharacter(";")); + EVDEV_TO_KEY.put(40, Key.ofCharacter("'")); + EVDEV_TO_KEY.put(41, Key.ofCharacter("`")); + EVDEV_TO_KEY.put(43, Key.backslash); + EVDEV_TO_KEY.put(51, Key.ofCharacter(",")); + EVDEV_TO_KEY.put(52, Key.ofCharacter(".")); + EVDEV_TO_KEY.put(53, Key.ofCharacter("/")); + + // Build reverse map; lower code wins for keys that share a mapping (e.g. enter=28, kp_enter=96) + EVDEV_TO_KEY.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> KEY_TO_EVDEV.putIfAbsent(e.getValue(), e.getKey())); + } + + /** + * Maps a Linux evdev keycode (EV_KEY code) to a Key. + * Character keys assume a QWERTY physical layout. + */ + public static Key fromEvdevCode(int code) { + return EVDEV_TO_KEY.get(code); + } + + /** + * Maps a Key back to its canonical evdev keycode for uinput injection. + * Returns null if no evdev code is known for the key. + */ + public static Integer toEvdevCode(Key key) { + return KEY_TO_EVDEV.get(key); + } + + /** + * Maps an X11 keysym name (from XKeysymToString) to a Key. + * Single-character keysym names (e.g. "a", "b", "1") are resolved via Key.ofCharacter. + * Returns null if the keysym has no mapping. + */ + public static Key fromKeysym(String keysym) { + if (keysym == null) { + return null; + } + Key mapped = KEYSYM_TO_KEY.get(keysym); + if (mapped != null) { + return mapped; + } + if (keysym.length() == 1) { + return Key.ofCharacter(keysym); + } + return null; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/WaylandMouse.java b/src/main/java/mousemaster/platform/linux/WaylandMouse.java new file mode 100644 index 00000000..d5a21723 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/WaylandMouse.java @@ -0,0 +1,348 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import mousemaster.Rectangle; +import mousemaster.Screen; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; + +/** + * Wayland implementation of MouseController, using the wlr-protocols + * zwlr_virtual_pointer_v1 extension for cursor motion. Only supported on + * wlroots-based compositors (Hyprland, Sway) that advertise + * zwlr_virtual_pointer_manager_v1 - see markUnavailable() for the fallback + * behavior on compositors that don't (GNOME, KDE Plasma). + */ +public class WaylandMouse extends LinuxMouse { + + private static final Logger logger = LoggerFactory.getLogger(WaylandMouse.class); + + // MouseManager passes wheel delta in Windows' WHEEL_DELTA convention (120 units = one + // notch, see WindowsMouseController), since that's the shared unit the velocity config + // is tuned against. uinput's REL_WHEEL has no fractional-notch concept - each event is + // a discrete notch - so accumulate sub-notch deltas here and only fire once a full + // notch's worth has built up, carrying the remainder forward (same pattern MouseManager + // itself uses for cursor movement's deltaDistanceX/Y). + private static final double WHEEL_DELTA = 120; + + private final LinuxScreens screens; + private final int uinputMouseFd; + private final LibWaylandClient.WlRegistryListener registryListener; + private double verticalWheelAccumulator; + private double horizontalWheelAccumulator; + + private Pointer display; + private Pointer registry; + private Pointer seatProxy; + private Pointer managerProxy; + private Pointer virtualPointerProxy; + private boolean protocolAvailable; + + private boolean managerFound; + private int managerName; + private int managerVersion; + private boolean seatFound; + private int seatName; + private int seatVersion; + + private volatile Integer lastX; + private volatile Integer lastY; + + @Override + public Integer lastSyntheticX() { + return lastX; + } + + @Override + public Integer lastSyntheticY() { + return lastY; + } + + public WaylandMouse(LinuxScreens screens) { + this.screens = screens; + this.uinputMouseFd = LibUinput.createMouseDevice(); + this.registryListener = new LibWaylandClient.WlRegistryListener(); + registryListener.global = this::onGlobal; + registryListener.globalRemove = this::onGlobalRemove; + try { + connectAndBootstrap(); + } catch (Throwable e) { + // Catches Throwable, not just Exception: a failure to load libwayland-client.so + // surfaces as UnsatisfiedLinkError/NoClassDefFoundError, which are Errors, not + // Exceptions - without this, that would crash the whole app instead of just + // disabling cursor movement. + logger.error("Failed to initialize Wayland virtual pointer, cursor movement disabled", e); + protocolAvailable = false; + } + } + + private void connectAndBootstrap() { + display = LibWaylandClient.INSTANCE.wl_display_connect(null); + if (display == null) { + markUnavailable("wl_display_connect failed (no $WAYLAND_DISPLAY socket)"); + return; + } + + LibWaylandClient.WlArgs getRegistryArgs = new LibWaylandClient.WlArgs(1); + registry = LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(display, + LibWaylandClient.WL_DISPLAY_GET_REGISTRY, + LibWaylandClient.globalInterfacePointer("wl_registry_interface"), + LibWaylandClient.INSTANCE.wl_proxy_get_version(display), 0, getRegistryArgs.pointer()); + + registryListener.write(); + LibWaylandClient.INSTANCE.wl_proxy_add_listener(registry, registryListener.getPointer(), null); + LibWaylandClient.INSTANCE.wl_display_roundtrip(display); // collects the initial batch of `global` events + + if (!managerFound) { + markUnavailable("compositor did not advertise zwlr_virtual_pointer_manager_v1"); + return; + } + managerProxy = bind(managerName, managerVersion, + LibWlrVirtualPointer.ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE.getPointer(), + "zwlr_virtual_pointer_manager_v1"); + if (seatFound) + seatProxy = bind(seatName, seatVersion, + LibWaylandClient.globalInterfacePointer("wl_seat_interface"), "wl_seat"); + + virtualPointerProxy = createVirtualPointer(); + LibWaylandClient.INSTANCE.wl_display_flush(display); + protocolAvailable = virtualPointerProxy != null; + logger.info("Wayland virtual pointer bootstrapped (manager v{}, seat {})", + managerVersion, seatProxy != null ? "bound" : "none"); + } + + private void onGlobal(Pointer data, Pointer registryProxy, int name, String interfaceName, int version) { + if ("zwlr_virtual_pointer_manager_v1".equals(interfaceName)) { + managerName = name; + managerVersion = Math.min(version, 2); + managerFound = true; + } else if ("wl_seat".equals(interfaceName) && !seatFound) { + seatName = name; + seatVersion = version; + seatFound = true; + } + } + + private void onGlobalRemove(Pointer data, Pointer registryProxy, int name) { + } + + private Pointer bind(int name, int version, Pointer interfacePointer, String interfaceName) { + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(4); + args.setUint(0, name); + args.setString(1, interfaceName); + args.setUint(2, version); + return LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(registry, + LibWaylandClient.WL_REGISTRY_BIND, interfacePointer, version, 0, args.pointer()); + } + + private Pointer createVirtualPointer() { + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(2); + if (seatProxy != null) + args.setObject(0, seatProxy); + return LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(managerProxy, + LibWlrVirtualPointer.MGR_OP_CREATE_VIRTUAL_POINTER, + LibWlrVirtualPointer.ZWLR_VIRTUAL_POINTER_V1_INTERFACE.getPointer(), + managerVersion, 0, args.pointer()); + } + + private void markUnavailable(String reason) { + protocolAvailable = false; + String desktop = System.getenv("XDG_CURRENT_DESKTOP"); + logger.warn("Wayland cursor movement unavailable: {}. zwlr_virtual_pointer_v1 is supported by " + + "wlroots-based compositors (Hyprland, Sway) but not GNOME or KDE Plasma. Cursor movement " + + "will be disabled for this session; keyboard capture and hint navigation are unaffected.{}", + reason, desktop != null ? " Detected desktop: " + desktop + "." : ""); + } + + private boolean ensureAvailable() { + return protocolAvailable; + } + + // ---- MouseController: cursor motion, via zwlr_virtual_pointer_v1 ---- + + @Override + public void beginMove() { + } + + @Override + public void endMove() { + if (protocolAvailable) + LibWaylandClient.INSTANCE.wl_display_flush(display); + } + + @Override + public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { + if (!ensureAvailable() || (dx == 0 && dy == 0)) + return; + double signedDx = xForward ? dx : -dx; + double signedDy = yForward ? dy : -dy; + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(3); + args.setUint(0, (int) System.currentTimeMillis()); + args.setFixed(1, LibWaylandClient.fixedFromDouble(signedDx)); + args.setFixed(2, LibWaylandClient.fixedFromDouble(signedDy)); + marshalPointerRequest(LibWlrVirtualPointer.VP_OP_MOTION, args.pointer()); + frame(); + LibWaylandClient.INSTANCE.wl_display_flush(display); + if (lastX != null && lastY != null) { + lastX += (int) Math.round(signedDx); + lastY += (int) Math.round(signedDy); + } + } + + @Override + public void synchronousMoveTo(int x, int y) { + if (!ensureAvailable()) + return; + Rectangle bounds = screenBounds(); + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(5); + args.setUint(0, (int) System.currentTimeMillis()); + args.setUint(1, x - bounds.x()); + args.setUint(2, y - bounds.y()); + args.setUint(3, bounds.width()); + args.setUint(4, bounds.height()); + marshalPointerRequest(LibWlrVirtualPointer.VP_OP_MOTION_ABSOLUTE, args.pointer()); + frame(); + LibWaylandClient.INSTANCE.wl_display_flush(display); + lastX = x; + lastY = y; + } + + // Uncached on purpose, matching how ScreenManager/HintManager/GridManager already + // call Screens.findScreens() uncached at similar frequency elsewhere in the app. + private Rectangle screenBounds() { + Set allScreens = screens.findScreens(); + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE; + for (Screen screen : allScreens) { + Rectangle r = screen.rectangle(); + minX = Math.min(minX, r.x()); + minY = Math.min(minY, r.y()); + maxX = Math.max(maxX, r.x() + r.width()); + maxY = Math.max(maxY, r.y() + r.height()); + } + return new Rectangle(minX, minY, maxX - minX, maxY - minY); + } + + private void marshalPointerRequest(int opcode, Pointer argsPointer) { + LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(virtualPointerProxy, opcode, Pointer.NULL, + LibWaylandClient.INSTANCE.wl_proxy_get_version(virtualPointerProxy), 0, argsPointer); + } + + private void frame() { + marshalPointerRequest(LibWlrVirtualPointer.VP_OP_FRAME, Pointer.NULL); + } + + // ---- MouseController: buttons/wheel ---- + // + // Route A (chosen over adding button/axis/frame calls to the Wayland protocol above): + // clicks and scroll go through the kernel uinput mouse device (LibUinput.createMouseDevice(), + // already built and unused elsewhere) instead of zwlr_virtual_pointer_v1's own button/axis + // opcodes. That device's own docstring already describes it as working on "both X11 and + // native Wayland", and reusing it is far less new/risky code than hand-marshalling three more + // wire opcodes. This mirrors how keyboard input already works on Linux (evdev capture + uinput + // injection, independent of display server) - the deliberate trade-off is that WaylandMouse + // ends up using two transports (Wayland wire protocol for motion, kernel uinput for clicks and + // wheel), both of which are ultimately resolved by the same compositor. + + @Override + public void pressLeft() { + uinputButton(LibUinput.BTN_LEFT, 1); + } + + @Override + public void releaseLeft() { + uinputButton(LibUinput.BTN_LEFT, 0); + } + + @Override + public void pressMiddle() { + uinputButton(LibUinput.BTN_MIDDLE, 1); + } + + @Override + public void releaseMiddle() { + uinputButton(LibUinput.BTN_MIDDLE, 0); + } + + @Override + public void pressRight() { + uinputButton(LibUinput.BTN_RIGHT, 1); + } + + @Override + public void releaseRight() { + uinputButton(LibUinput.BTN_RIGHT, 0); + } + + @Override + public void wheelVerticallyBy(boolean forward, double delta) { + verticalWheelAccumulator += delta; + int notches = (int) (verticalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + verticalWheelAccumulator -= notches * WHEEL_DELTA; + uinputWheel(LibUinput.REL_WHEEL, forward ? 1 : -1, notches); + } + + @Override + public void wheelHorizontallyBy(boolean forward, double delta) { + horizontalWheelAccumulator += delta; + int notches = (int) (horizontalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + horizontalWheelAccumulator -= notches * WHEEL_DELTA; + uinputWheel(LibUinput.REL_HWHEEL, forward ? 1 : -1, notches); + } + + private void uinputButton(int code, int value) { + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_KEY, code, value); + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); + } + + private void uinputWheel(int axisCode, int sign, int notches) { + for (int i = 0; i < notches; i++) + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_REL, axisCode, sign); + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); + } + + // ---- MouseController: cursor visibility ---- + // + // Permanent scope boundary, not a bug to fix later: zwlr_virtual_pointer_v1 has no + // cursor-image request. Wayland cursor sprites are normally controlled via + // wl_pointer.set_cursor, which requires holding pointer focus over one of the client's + // own surfaces - a pure input-injection virtual pointer has no surface and no pointer + // focus of its own, so there is no way to hide/change the system cursor from here. + + @Override + public void showCursor() { + logger.debug("showCursor() is a no-op under Wayland - see WaylandMouse class comment"); + } + + @Override + public void hideCursor() { + logger.debug("hideCursor() is a no-op under Wayland - see WaylandMouse class comment"); + } + + @Override + public void destroy() { + if (virtualPointerProxy != null) + destroyRemoteObject(virtualPointerProxy, LibWlrVirtualPointer.VP_OP_DESTROY); + if (managerProxy != null) + destroyRemoteObject(managerProxy, LibWlrVirtualPointer.MGR_OP_DESTROY); + if (seatProxy != null) + LibWaylandClient.INSTANCE.wl_proxy_destroy(seatProxy); + if (registry != null) + LibWaylandClient.INSTANCE.wl_proxy_destroy(registry); + if (display != null) + LibWaylandClient.INSTANCE.wl_display_disconnect(display); + LibUinput.destroyDevice(uinputMouseFd); + } + + private void destroyRemoteObject(Pointer proxy, int destroyOpcode) { + LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(proxy, destroyOpcode, Pointer.NULL, + LibWaylandClient.INSTANCE.wl_proxy_get_version(proxy), + LibWaylandClient.WL_MARSHAL_FLAG_DESTROY, Pointer.NULL); + } +} diff --git a/src/main/java/mousemaster/platform/linux/X11Mouse.java b/src/main/java/mousemaster/platform/linux/X11Mouse.java new file mode 100644 index 00000000..9d80b194 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/X11Mouse.java @@ -0,0 +1,156 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// X11-only implementation. See WaylandMouse for the zwlr_virtual_pointer_v1-based +// implementation used under Wayland. +public class X11Mouse extends LinuxMouse { + + private static final Logger logger = LoggerFactory.getLogger(X11Mouse.class); + + // X11 button numbers + private static final int BTN_LEFT = 1; + private static final int BTN_MIDDLE = 2; + private static final int BTN_RIGHT = 3; + // Vertical scroll: 4 = up, 5 = down + // Horizontal scroll: 6 = left, 7 = right + + // MouseManager passes wheel delta in Windows' WHEEL_DELTA convention (120 units = one + // notch, see WindowsMouseController), since that's the shared unit the velocity config + // is tuned against. X11 has no fractional-notch concept - button4/5/6/7 are discrete + // click events - so accumulate sub-notch deltas here and only fire once a full notch's + // worth has built up, carrying the remainder forward (same pattern MouseManager itself + // uses for cursor movement's deltaDistanceX/Y). + private static final double WHEEL_DELTA = 120; + + private final Pointer display; + private final long rootWindow; + private long hiddenCursor = 0; + private double verticalWheelAccumulator; + private double horizontalWheelAccumulator; + + public X11Mouse(Pointer display, long rootWindow) { + this.display = display; + this.rootWindow = rootWindow; + } + + @Override + public void destroy() { + if (hiddenCursor != 0) { + LibX11.INSTANCE.XFreeCursor(display, hiddenCursor); + hiddenCursor = 0; + } + } + + @Override + public void beginMove() { + } + + @Override + public void endMove() { + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { + int ix = (int) dx * (xForward ? 1 : -1); + int iy = (int) dy * (yForward ? 1 : -1); + if (ix == 0 && iy == 0) return; + // dest_window = None (0) → coords are relative to current pointer position + LibX11.INSTANCE.XWarpPointer(display, 0, 0, 0, 0, 0, 0, ix, iy); + } + + @Override + public void synchronousMoveTo(int x, int y) { + LibX11.INSTANCE.XWarpPointer(display, 0, rootWindow, 0, 0, 0, 0, x, y); + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void pressLeft() { + buttonEvent(BTN_LEFT, true); + } + + @Override + public void releaseLeft() { + buttonEvent(BTN_LEFT, false); + } + + @Override + public void pressMiddle() { + buttonEvent(BTN_MIDDLE, true); + } + + @Override + public void releaseMiddle() { + buttonEvent(BTN_MIDDLE, false); + } + + @Override + public void pressRight() { + buttonEvent(BTN_RIGHT, true); + } + + @Override + public void releaseRight() { + buttonEvent(BTN_RIGHT, false); + } + + @Override + public void wheelVerticallyBy(boolean forward, double delta) { + // forward = away from user = scroll up = button 4 + int button = forward ? 4 : 5; + verticalWheelAccumulator += delta; + int notches = (int) (verticalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + verticalWheelAccumulator -= notches * WHEEL_DELTA; + for (int i = 0; i < notches; i++) { + buttonEvent(button, true); + buttonEvent(button, false); + } + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void wheelHorizontallyBy(boolean forward, double delta) { + // forward = right = button 7 + int button = forward ? 7 : 6; + horizontalWheelAccumulator += delta; + int notches = (int) (horizontalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + horizontalWheelAccumulator -= notches * WHEEL_DELTA; + for (int i = 0; i < notches; i++) { + buttonEvent(button, true); + buttonEvent(button, false); + } + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void hideCursor() { + if (hiddenCursor == 0) { + byte[] blankData = {0}; + long pixmap = LibX11.INSTANCE.XCreateBitmapFromData(display, rootWindow, blankData, 1, 1); + LibX11.XColor black = new LibX11.XColor(); + hiddenCursor = LibX11.INSTANCE.XCreatePixmapCursor(display, pixmap, pixmap, black, black, 0, 0); + LibX11.INSTANCE.XFreePixmap(display, pixmap); + } + LibX11.INSTANCE.XDefineCursor(display, rootWindow, hiddenCursor); + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void showCursor() { + LibX11.INSTANCE.XUndefineCursor(display, rootWindow); + LibX11.INSTANCE.XFlush(display); + } + + private void buttonEvent(int button, boolean press) { + LibXTest.INSTANCE.XTestFakeButtonEvent(display, button, press ? 1 : 0, 0); + LibX11.INSTANCE.XFlush(display); + } +} diff --git a/src/main/java/mousemaster/platform/linux/X11Test.java b/src/main/java/mousemaster/platform/linux/X11Test.java new file mode 100644 index 00000000..4457fc5b --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/X11Test.java @@ -0,0 +1,85 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; + +/** + * Simple test to verify X11 event handling is working + */ +public class X11Test { + public static void main(String[] args) throws InterruptedException { + System.out.println("X11 Event Test - Press keys to test detection..."); + + Pointer display = LibX11.INSTANCE.XOpenDisplay(null); + if (display == null) { + System.err.println("Failed to open X11 display"); + return; + } + System.out.println("Display opened successfully"); + + long rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + System.out.println("Root window: " + rootWindow); + + LibX11.INSTANCE.XSelectInput(display, rootWindow, + LibX11.KeyPressMask | LibX11.KeyReleaseMask); + System.out.println("XSelectInput called with mask: " + + (LibX11.KeyPressMask | LibX11.KeyReleaseMask)); + + LibX11.INSTANCE.XFlush(display); + + System.out.println("\nAttempting to grab keyboard..."); + int grabResult = LibX11.INSTANCE.XGrabKeyboard(display, rootWindow, 1, + LibX11.GrabModeAsync, LibX11.GrabModeAsync, LibX11.CurrentTime); + System.out.println("XGrabKeyboard result: " + grabResult); + + if (grabResult == LibX11.GrabSuccess) { + System.out.println("Keyboard grabbed successfully!"); + } else { + System.out.println("Failed to grab keyboard: " + grabResult); + } + + LibX11.INSTANCE.XFlush(display); + + System.out.println("\nListening for events for 10 seconds..."); + long startTime = System.currentTimeMillis(); + int totalEvents = 0; + + while (System.currentTimeMillis() - startTime < 10000) { + int pending = LibX11.INSTANCE.XPending(display); + if (pending > 0) { + System.out.println("Events pending: " + pending); + } + + while (LibX11.INSTANCE.XPending(display) > 0) { + LibX11.XEvent event = new LibX11.XEvent(); + LibX11.INSTANCE.XNextEvent(display, event); + totalEvents++; + + System.out.println("Event type: " + event.type); + + if (event.type == LibX11.KeyPress) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + System.out.println("KeyPress: " + keyString + " (keycode: " + keyEvent.keycode + ")"); + } else if (event.type == LibX11.KeyRelease) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + System.out.println("KeyRelease: " + keyString + " (keycode: " + keyEvent.keycode + ")"); + } + } + + Thread.sleep(10); + } + + System.out.println("\nTotal events received: " + totalEvents); + + if (grabResult == LibX11.GrabSuccess) { + LibX11.INSTANCE.XUngrabKeyboard(display, LibX11.CurrentTime); + System.out.println("Keyboard ungrabbed"); + } + + LibX11.INSTANCE.XCloseDisplay(display); + System.out.println("Display closed"); + } +} diff --git a/src/main/java/mousemaster/platform/linux/X11Test2.java b/src/main/java/mousemaster/platform/linux/X11Test2.java new file mode 100644 index 00000000..881a3e9b --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/X11Test2.java @@ -0,0 +1,105 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; + +/** + * Test X11 keyboard events with sync and better debugging + */ +public class X11Test2 { + public static void main(String[] args) throws InterruptedException { + System.out.println("X11 Keyboard Test v2"); + System.out.println("After keyboard is grabbed, press some keys..."); + System.out.println("Press Escape to exit early\n"); + + Pointer display = LibX11.INSTANCE.XOpenDisplay(null); + if (display == null) { + System.err.println("Failed to open X11 display"); + return; + } + System.out.println("Display opened"); + + long rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + System.out.println("Root window: " + rootWindow); + + LibX11.INSTANCE.XSync(display, false); + + Thread.sleep(1000); + + System.out.println("\nGrabbing keyboard..."); + int grabResult = LibX11.INSTANCE.XGrabKeyboard( + display, + rootWindow, + 0, + LibX11.GrabModeAsync, + LibX11.GrabModeAsync, + LibX11.CurrentTime + ); + + if (grabResult != LibX11.GrabSuccess) { + System.err.println("Failed to grab keyboard: " + grabResult); + LibX11.INSTANCE.XCloseDisplay(display); + return; + } + + System.out.println("Keyboard grabbed! Press keys now...\n"); + + LibX11.INSTANCE.XSync(display, false); + + boolean running = true; + int totalEvents = 0; + long startTime = System.currentTimeMillis(); + + while (running && (System.currentTimeMillis() - startTime < 15000)) { + LibX11.INSTANCE.XSync(display, false); + int pending = LibX11.INSTANCE.XPending(display); + + if (pending > 0) { + System.out.println(">>> " + pending + " events pending"); + } + + while (LibX11.INSTANCE.XPending(display) > 0) { + LibX11.XEvent event = new LibX11.XEvent(); + LibX11.INSTANCE.XNextEvent(display, event); + totalEvents++; + + System.out.println("Event #" + totalEvents + " - Type: " + event.type); + + if (event.type == LibX11.KeyPress) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + + System.out.println(" KeyPress: " + keyString + + " (keycode=" + keyEvent.keycode + + ", state=" + keyEvent.state + + ", window=" + keyEvent.window + ")"); + + if ("Escape".equals(keyString)) { + System.out.println(" Escape pressed - exiting"); + running = false; + } + + } else if (event.type == LibX11.KeyRelease) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + + System.out.println(" KeyRelease: " + keyString + + " (keycode=" + keyEvent.keycode + ")"); + } + } + + Thread.sleep(10); + } + + System.out.println("\n========================="); + System.out.println("Total events received: " + totalEvents); + + LibX11.INSTANCE.XUngrabKeyboard(display, LibX11.CurrentTime); + LibX11.INSTANCE.XSync(display, false); + System.out.println("Keyboard ungrabbed"); + + LibX11.INSTANCE.XCloseDisplay(display); + System.out.println("Display closed"); + } +} diff --git a/src/main/java/mousemaster/qt/ScreenshotWidget.java b/src/main/java/mousemaster/qt/ScreenshotWidget.java index 7d92b8ac..f9a6e5e1 100644 --- a/src/main/java/mousemaster/qt/ScreenshotWidget.java +++ b/src/main/java/mousemaster/qt/ScreenshotWidget.java @@ -49,11 +49,31 @@ protected void paintEvent(QPaintEvent event) { QPainter painter = new QPainter(this); painter.fillRect(0, 0, width(), height(), new QColor(0, 0, 0)); painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, true); - QRectF sourceRect = new QRectF(sourceX, sourceY, sourceWidth, sourceHeight); - QRectF targetRect = new QRectF(0, 0, width(), height()); - painter.drawPixmap(targetRect, pixmap, sourceRect); - sourceRect.dispose(); - targetRect.dispose(); + + // Near a screen edge/corner, the ideal source rect can extend past the + // captured pixmap's bounds. Clamp it and shrink the target rect by the same + // scale so the valid pixels still land in the right place, and the region + // with no captured pixels stays as the black fill above instead of leaving + // a gap where drawPixmap would otherwise paint nothing (transparent, letting + // whatever is behind this widget show through). + double clampedSourceX = Math.max(sourceX, 0); + double clampedSourceY = Math.max(sourceY, 0); + double clampedSourceRight = Math.min(sourceX + sourceWidth, pixmap.width()); + double clampedSourceBottom = Math.min(sourceY + sourceHeight, pixmap.height()); + double clampedSourceWidth = Math.max(0, clampedSourceRight - clampedSourceX); + double clampedSourceHeight = Math.max(0, clampedSourceBottom - clampedSourceY); + + if (clampedSourceWidth > 0 && clampedSourceHeight > 0) { + double targetX = (clampedSourceX - sourceX) * zoomPercent; + double targetY = (clampedSourceY - sourceY) * zoomPercent; + QRectF sourceRect = new QRectF(clampedSourceX, clampedSourceY, + clampedSourceWidth, clampedSourceHeight); + QRectF targetRect = new QRectF(targetX, targetY, + clampedSourceWidth * zoomPercent, clampedSourceHeight * zoomPercent); + painter.drawPixmap(targetRect, pixmap, sourceRect); + sourceRect.dispose(); + targetRect.dispose(); + } painter.end(); painter.dispose(); } diff --git a/src/main/java/mousemaster/qt/TransparentWindow.java b/src/main/java/mousemaster/qt/TransparentWindow.java index 4a1d7b38..3ade9e1a 100644 --- a/src/main/java/mousemaster/qt/TransparentWindow.java +++ b/src/main/java/mousemaster/qt/TransparentWindow.java @@ -14,9 +14,21 @@ public class TransparentWindow extends QWidget { private QRect backgroundRect; public TransparentWindow() { - // WindowDoesNotAcceptFocus is not implemented for Windows. - setWindowFlags(Qt.WindowType.FramelessWindowHint); + // FramelessWindowHint: no title bar or border. + // X11BypassWindowManagerHint: bypass tiling WMs (e.g. Hyprland via XWayland) so the + // window floats at the exact geometry we specify rather than being tiled. + // WindowStaysOnTopHint: appear above all other windows. + setWindowFlags(Qt.WindowType.FramelessWindowHint, + Qt.WindowType.X11BypassWindowManagerHint, + Qt.WindowType.WindowStaysOnTopHint); setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground); + // Click-through: the shared renderers (GridRenderer/HintMeshRenderer) never + // actually unmap their windows on hide(), only clear the drawn content and rely + // on this being permanently click-through - Windows gets the same behavior via + // WS_EX_TRANSPARENT applied once at HWND creation (see WindowsOverlay). Without + // this, a hidden-but-still-mapped, full-desktop/full-screen overlay window + // silently swallows every click underneath it, forever, from first use onward. + setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents); } public void setBackground(QColor color, QRect rect) {