windowsResourcesPaths = List.of(
"qt/bin/Qt6Core.dll",
@@ -46,7 +52,15 @@ public class QtManager {
"qt-msvcp/msvcp140_2.dll"
);
- public static void initialize() throws IOException {
+ public static void initialize(DeploymentStrategy deploymentStrategy) throws IOException {
+ switch (deploymentStrategy) {
+ case WINDOWS_EXTRACTED_RESOURCES -> initializeWindows();
+ case QTJAMBI_DEFAULT -> initializeDefault();
+ }
+ initialized = true;
+ }
+
+ private static void initializeWindows() throws IOException {
File extractDirectory = createExtractDirectory(
MousemasterApplication.tempDirectory);
for (String resourcesPath : windowsResourcesPaths) {
@@ -88,6 +102,15 @@ public static void initialize() throws IOException {
// QApplication.initialize(new String[] { });
}
+ private static void initializeDefault() {
+ // Used by macOS and other platforms where Qt Jambi native artifacts are
+ // loaded from the normal dependency/deployment path instead of extracted
+ // Windows DLL resources. macOS launchers must still provide
+ // -XstartOnFirstThread so AppKit/Qt runs on the process first thread.
+ QtUtilities.jambiDeploymentDir();
+ QApplication.initialize(new String[] {});
+ }
+
private static void extractResourceFile(String resourcesPath, Path extractPath)
throws IOException {
try (InputStream inputStream = MousemasterApplication.class.getClassLoader().getResourceAsStream(
@@ -104,11 +127,15 @@ private static void extractResourceFile(String resourcesPath, Path extractPath)
}
public static void stop() {
- QApplication.shutdown();
+ if (initialized) {
+ QApplication.shutdown();
+ initialized = false;
+ }
}
public static void processEvents() {
- QApplication.processEvents();
+ if (initialized)
+ QApplication.processEvents();
}
private static File createExtractDirectory(String tempDirectory) throws IOException {
diff --git a/src/main/java/mousemaster/platform/windows/WindowsVirtualKey.java b/src/main/java/mousemaster/WindowsVirtualKey.java
similarity index 51%
rename from src/main/java/mousemaster/platform/windows/WindowsVirtualKey.java
rename to src/main/java/mousemaster/WindowsVirtualKey.java
index 8bbb3fa6..5eaa0d34 100644
--- a/src/main/java/mousemaster/platform/windows/WindowsVirtualKey.java
+++ b/src/main/java/mousemaster/WindowsVirtualKey.java
@@ -1,19 +1,19 @@
-package mousemaster.platform.windows;
-
-import mousemaster.*;
-
-import com.sun.jna.Native;
-import com.sun.jna.Pointer;
-import com.sun.jna.platform.win32.User32;
-import com.sun.jna.platform.win32.WinDef;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+package mousemaster;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+/**
+ * Windows virtual-key identifiers used by bundled keyboard-layout metadata.
+ *
+ * This enum intentionally has no Win32 behavior. It is shared data in the same
+ * sense as a Windows scan code: a stable identifier that lets
+ * {@link KeyboardLayout} describe how a layout maps physical keys to
+ * mousemaster {@link Key}s. Code that talks to Win32, polls the active layout,
+ * or translates hook events belongs in {@code mousemaster.platform.windows}.
+ */
public enum WindowsVirtualKey {
VK_LBUTTON(0x01),
@@ -343,9 +343,12 @@ public enum WindowsVirtualKey {
this.virtualKeyCode = virtualKeyCode;
}
+ /**
+ * Lookup table indexed by native virtual-key code. Duplicate aliases are
+ * collapsed to the first enum constant declared for that numeric code.
+ */
public static final List values;
- private static final Logger logger = LoggerFactory.getLogger(WindowsVirtualKey.class);
static {
WindowsVirtualKey[] valueArrayWithDuplicateCodes = values();
@@ -358,150 +361,4 @@ public enum WindowsVirtualKey {
values = Arrays.stream(valueArrayWithoutDuplicateCodes).toList();
}
- private static KeyboardLayout lastPolledActiveKeyboardLayout;
- private static int keyboardLayoutSeenCount;
- private static boolean lastActiveKeyboardLayoutFailed;
-
- /**
- * When changing the layout with win + space:
- * when opening the Win start menu, the layout of that hwnd would be the old layout for
- * a few milliseconds. The workaround here waits for the layout to show up twice before
- * confirming it has changed.
- */
- public static KeyboardLayout activeKeyboardLayout() {
- KeyboardLayout foregroundWindowKeyboardLayout = foregroundWindowKeyboardLayout();
- if (foregroundWindowKeyboardLayout != null) {
- if (!foregroundWindowKeyboardLayout.equals(lastPolledActiveKeyboardLayout)) {
- if (lastPolledActiveKeyboardLayout != null && keyboardLayoutSeenCount++ < 2)
- return lastPolledActiveKeyboardLayout;
- // New layout confirmed.
- keyboardLayoutSeenCount = 0;
- WinDef.HWND hwnd = User32.INSTANCE.GetForegroundWindow();
- String hwndString = hwnd == null ? null :
- String.format("0x%X", Pointer.nativeValue(hwnd.getPointer()));
- logger.trace("Found foreground window's keyboard layout for hwnd " + hwndString + ": " +
- foregroundWindowKeyboardLayout);
- }
- lastPolledActiveKeyboardLayout = foregroundWindowKeyboardLayout;
- lastActiveKeyboardLayoutFailed = false;
- return foregroundWindowKeyboardLayout;
- }
- // When changing the active window, the foreground window may be null for a short period of time (?).
- if (lastPolledActiveKeyboardLayout != null) {
- if (!lastActiveKeyboardLayoutFailed)
- logger.trace(
- "Unable to find the foreground window's keyboard layout, using last known keyboard layout " +
- lastPolledActiveKeyboardLayout);
- lastActiveKeyboardLayoutFailed = true;
- return lastPolledActiveKeyboardLayout;
- }
- KeyboardLayout startupKeyboardLayout = startupKeyboardLayout();
- if (!lastActiveKeyboardLayoutFailed)
- logger.trace(
- "Unable to find the foreground window's keyboard layout, using start up keyboard layout " +
- startupKeyboardLayout);
- lastPolledActiveKeyboardLayout = startupKeyboardLayout;
- lastActiveKeyboardLayoutFailed = true;
- return startupKeyboardLayout;
- }
-
- private static int lastFailedGetKeyboardLayoutThreadId = -1;
-
- private static WinDef.HKL foregroundWindowHkl() {
- WinDef.HWND hwnd = User32.INSTANCE.GetForegroundWindow();
- if (hwnd == null) {
- // GetForegroundWindow does not set the last error value.
- logger.trace("GetForegroundWindow failed");
- }
- else {
- int thread = User32.INSTANCE.GetWindowThreadProcessId(hwnd, null);
- if (thread == 0) {
- logger.error("GetWindowThreadProcessId failed: " + Integer.toHexString(
- Native.getLastError()));
- }
- else {
- WinDef.HKL hkl = User32.INSTANCE.GetKeyboardLayout(thread);
- if (hkl == null) {
- if (lastFailedGetKeyboardLayoutThreadId != thread) {
- // GetKeyboardLayout does not set the last error value.
- logger.error("GetKeyboardLayout failed");
- // Avoid flooding the logs with the same error.
- lastFailedGetKeyboardLayoutThreadId = thread;
- }
- }
- else {
- lastFailedGetKeyboardLayoutThreadId = -1;
- }
- return hkl;
- }
- }
- return null;
- }
-
- private static KeyboardLayout foregroundWindowKeyboardLayout() {
- WinDef.HKL hkl = foregroundWindowHkl();
- if (hkl != null) {
- // The mousemaster.exe command line window does not handle the WM_INPUTLANGCHANGE message.
- // Therefore, when the user changes the layout, the command line window keeps the old layout.
- // We call ActivateKeyboardLayout to change the layout of the command line window.
- ExtendedUser32.INSTANCE.ActivateKeyboardLayout(hkl, 0);
- int languageIdentifier = hkl.getLanguageIdentifier();
- KeyboardLayout keyboardLayout = KeyboardLayout.keyboardLayoutByIdentifier.get(
- String.format("%08X", languageIdentifier));
-// logger.debug("Found active window keyboard layout: " + keyboardLayout);
- return keyboardLayout;
- }
- return null;
- }
-
- private static KeyboardLayout startupKeyboardLayout() {
- // GetKeyboardLayoutName returns the layout at the time of when the app was started.
- // If the system layout is changed after the app is started, GetKeyboardLayoutName
- // still returns the old layout.
- char[] nameBuffer = new char[User32.KL_NAMELENGTH];
- User32.INSTANCE.GetKeyboardLayoutName(nameBuffer);
- int nameLength = nameBuffer.length;
- for (int i = 0; i < nameBuffer.length; i++) {
- if (nameBuffer[i] == 0) {
- nameLength = i;
- break;
- }
- }
- return KeyboardLayout.keyboardLayoutByIdentifier.get(
- new String(nameBuffer, 0, nameLength));
- }
-
- public static Key keyFromWindowsEvent(WindowsVirtualKey windowsVirtualKey, int scanCode,
- int flags, KeyboardLayout activeKeyboardLayout) {
- if (scanCode == 0) {
- // Injected key event have scanCode 0.
- return WindowsVirtualKey.activeKeyboardLayout().keyFromVirtualKey(windowsVirtualKey);
- }
- // When pressing rightctrl the scanCode should be E01D but is 1D (which is leftctrl's scanCode).
- // rightctrl:
- // Received key event: vkCode = 0xa3 (VK_RCONTROL), scanCode = 0x1d, flags = 0x1, wParam = WM_KEYDOWN
- // leftctrl:
- // Received key event: vkCode = 0xa2 (VK_LCONTROL), scanCode = 0x1d, flags = 0x0, wParam = WM_KEYDOWN
- // For rightshift, flag is 1 but it is not an extended key (scanCode is not E036 and really is 36):
- // Received key event: vkCode = 0xa1 (VK_RSHIFT), scanCode = 0x36, flags = 0x1, wParam = WM_KEYDOWN
- boolean isExtended = (flags & 0x1) != 0;
- if (isExtended) {
- int extendedKeyScanCode = 0xE000 | scanCode;
- Key extendedKey = activeKeyboardLayout.keyFromScanCode(extendedKeyScanCode);
- if (extendedKey != null)
- return extendedKey;
- }
- return activeKeyboardLayout.keyFromScanCode(scanCode);
- }
-
- public static WindowsVirtualKey windowsVirtualKeyFromKey(Key key,
- KeyboardLayout keyboardLayout) {
- WindowsVirtualKey virtualKey = keyboardLayout.virtualKey(key);
- if (virtualKey == null) {
- logger.debug("Unable to map key " + key + " to a Windows virtual key using " +
- keyboardLayout);
- }
- return virtualKey;
- }
-
}
diff --git a/src/main/java/mousemaster/platform/macos/MacosActiveAppFinder.java b/src/main/java/mousemaster/platform/macos/MacosActiveAppFinder.java
new file mode 100644
index 00000000..7f18e255
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosActiveAppFinder.java
@@ -0,0 +1,12 @@
+package mousemaster.platform.macos;
+
+import mousemaster.App;
+import mousemaster.platform.ActiveAppFinder;
+
+public class MacosActiveAppFinder implements ActiveAppFinder {
+
+ @Override
+ public App activeApp() {
+ return new App("unknown.macos.app");
+ }
+}
diff --git a/src/main/java/mousemaster/platform/macos/MacosConsole.java b/src/main/java/mousemaster/platform/macos/MacosConsole.java
new file mode 100644
index 00000000..1f121d02
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosConsole.java
@@ -0,0 +1,14 @@
+package mousemaster.platform.macos;
+
+import mousemaster.platform.Console;
+
+public class MacosConsole implements Console {
+
+ @Override
+ public void show() {
+ }
+
+ @Override
+ public void hide() {
+ }
+}
diff --git a/src/main/java/mousemaster/platform/macos/MacosKeyCodes.java b/src/main/java/mousemaster/platform/macos/MacosKeyCodes.java
new file mode 100644
index 00000000..4aa85ee9
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosKeyCodes.java
@@ -0,0 +1,48 @@
+package mousemaster.platform.macos;
+
+import mousemaster.Key;
+import mousemaster.KeyboardLayout;
+
+import java.util.Map;
+
+/**
+ * Physical-key mapping between macOS {@code CGKeyCode}s and the scan codes used
+ * by bundled keyboard layouts.
+ *
+ * A macOS keycode and a Windows scan code both identify a key position, not the
+ * character produced by the active layout. That makes the mapping global: macOS
+ * code can translate {@code macKeyCode -> scanCode -> Key} against the current
+ * {@link KeyboardLayout}, while the layout file remains focused on Windows
+ * layout metadata and mousemaster's logical {@link Key}s.
+ *
+ * This table is intentionally empty until it can be generated reproducibly from
+ * a pinned Chromium {@code dom_code_data.inc}. A partial hand-written table
+ * would create hard-to-debug missing-key behavior.
+ */
+public final class MacosKeyCodes {
+
+ private static final Map scanCodeByMacKeyCode = Map.of();
+ private static final Map macKeyCodeByScanCode = Map.of();
+
+ private MacosKeyCodes() {
+ }
+
+ public static Integer scanCode(int macKeyCode) {
+ return scanCodeByMacKeyCode.get(macKeyCode);
+ }
+
+ public static Integer macKeyCode(int scanCode) {
+ return macKeyCodeByScanCode.get(scanCode);
+ }
+
+ public static Key keyFromMacKeyCode(int macKeyCode, KeyboardLayout keyboardLayout) {
+ Integer scanCode = scanCode(macKeyCode);
+ return scanCode == null ? null : keyboardLayout.keyFromScanCode(scanCode);
+ }
+
+ public static Integer macKeyCode(Key key, KeyboardLayout keyboardLayout) {
+ int scanCode = keyboardLayout.scanCode(key);
+ return scanCode == -1 ? null : macKeyCode(scanCode);
+ }
+
+}
diff --git a/src/main/java/mousemaster/platform/macos/MacosKeyboardController.java b/src/main/java/mousemaster/platform/macos/MacosKeyboardController.java
new file mode 100644
index 00000000..7cf7b566
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosKeyboardController.java
@@ -0,0 +1,50 @@
+package mousemaster.platform.macos;
+
+import mousemaster.Key;
+import mousemaster.KeyboardLayout;
+import mousemaster.ResolvedMacroMove;
+import mousemaster.platform.KeyboardController;
+
+import java.util.List;
+
+public class MacosKeyboardController implements KeyboardController {
+
+ private KeyboardLayout activeKeyboardLayout;
+
+ void activeKeyboardLayout(KeyboardLayout activeKeyboardLayout) {
+ this.activeKeyboardLayout = activeKeyboardLayout;
+ }
+
+ KeyboardLayout activeKeyboardLayout() {
+ return activeKeyboardLayout;
+ }
+
+ @Override
+ public void update(double delta) {
+ }
+
+ @Override
+ public void reset() {
+ }
+
+ @Override
+ public void sendInputMoves(List moves, boolean startRepeat) {
+ throw new UnsupportedOperationException("macOS keyboard event synthesis is not implemented yet");
+ }
+
+ @Override
+ public void keyPressedNotEaten(Key key) {
+ }
+
+ @Override
+ public void keyReleasedNotEaten(Key key) {
+ }
+
+ @Override
+ public void recordEarlyReleaseForQueuedPress(Key key) {
+ }
+
+ @Override
+ public void clearEarlyReleaseForQueuedPress(Key key) {
+ }
+}
diff --git a/src/main/java/mousemaster/platform/macos/MacosKeyboardLayout.java b/src/main/java/mousemaster/platform/macos/MacosKeyboardLayout.java
new file mode 100644
index 00000000..fdd78529
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosKeyboardLayout.java
@@ -0,0 +1,25 @@
+package mousemaster.platform.macos;
+
+import mousemaster.KeyboardLayout;
+
+public final class MacosKeyboardLayout {
+
+ /**
+ * Temporary scaffold layout. Production macOS support must build an
+ * in-memory layout from the active input source via TIS/UCKeyTranslate and
+ * use this only as an explicit fallback for unsupported input sources.
+ */
+ private static final String FALLBACK_LAYOUT_SHORT_NAME = "us-qwerty";
+
+ private MacosKeyboardLayout() {
+ }
+
+ public static KeyboardLayout activeKeyboardLayout() {
+ KeyboardLayout fallback =
+ KeyboardLayout.keyboardLayoutByShortName.get(FALLBACK_LAYOUT_SHORT_NAME);
+ if (fallback == null)
+ throw new IllegalStateException("Bundled fallback keyboard layout not found: " +
+ FALLBACK_LAYOUT_SHORT_NAME);
+ return fallback;
+ }
+}
diff --git a/src/main/java/mousemaster/platform/macos/MacosMain.java b/src/main/java/mousemaster/platform/macos/MacosMain.java
new file mode 100644
index 00000000..55483588
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosMain.java
@@ -0,0 +1,13 @@
+package mousemaster.platform.macos;
+
+import mousemaster.ApplicationLauncher;
+
+import java.io.IOException;
+
+public class MacosMain {
+
+ public static void main(String[] args) throws InterruptedException, IOException {
+ ApplicationLauncher.run(args, options -> new MacosPlatform(options.multipleInstancesAllowed(),
+ options.keyRegurgitationEnabled()));
+ }
+}
diff --git a/src/main/java/mousemaster/platform/macos/MacosPlatform.java b/src/main/java/mousemaster/platform/macos/MacosPlatform.java
new file mode 100644
index 00000000..18adba15
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosPlatform.java
@@ -0,0 +1,181 @@
+package mousemaster.platform.macos;
+
+import mousemaster.*;
+import mousemaster.platform.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+
+public class MacosPlatform implements Platform {
+
+ private static final Logger logger = LoggerFactory.getLogger(MacosPlatform.class);
+
+ private final MacosKeyboardController keyboard = new MacosKeyboardController();
+ private final MouseController mouse = new UnsupportedMouseController();
+ private final Screens screens = new MacosScreens();
+ private final Overlay overlay = new UnsupportedOverlay();
+ private final UiAutomation uiAutomation = new UnsupportedUiAutomation();
+ private final ActiveAppFinder activeAppFinder = new MacosActiveAppFinder();
+ private final Console console = new MacosConsole();
+ private final KeyRegurgitator keyRegurgitator = new KeyRegurgitator(keyboard);
+ // Temporary scaffold clock. Once CGEvent timestamps are handled, replace
+ // this with a monotonic clock that anchors native event time to Instant.
+ private final Clock clock = Instant::now;
+
+ public MacosPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationEnabled) {
+ logger.info("macOS platform scaffold initialized. Native input and overlay support are not wired yet.");
+ if (!multipleInstancesAllowed)
+ logger.warn("macOS single-instance locking is not implemented yet");
+ if (!keyRegurgitationEnabled)
+ logger.warn("macOS key regurgitation disable flag is accepted but keyboard support is not implemented yet");
+ }
+
+ @Override
+ public void update(double delta) {
+ keyboard.update(delta);
+ }
+
+ @Override
+ public void pumpEvents() {
+ QtManager.processEvents();
+ }
+
+ @Override
+ public void sleep() throws InterruptedException {
+ Thread.sleep(10);
+ }
+
+ @Override
+ public void reset(MouseManager mouseManager, KeyboardManager keyboardManager,
+ ModeMap modeMap, List mousePositionListeners,
+ KeyboardLayout activeKeyboardLayout) {
+ keyboard.activeKeyboardLayout(activeKeyboardLayout);
+ throw unsupported("macOS native event tap, mouse controller, and overlay are not implemented yet");
+ }
+
+ @Override
+ public void shutdown() {
+ QtManager.stop();
+ }
+
+ @Override
+ public QtManager.DeploymentStrategy qtDeploymentStrategy() {
+ return QtManager.DeploymentStrategy.QTJAMBI_DEFAULT;
+ }
+
+ @Override
+ public KeyRegurgitator keyRegurgitator() {
+ return keyRegurgitator;
+ }
+
+ @Override
+ public Clock clock() {
+ return clock;
+ }
+
+ @Override
+ public KeyboardLayout activeKeyboardLayout() {
+ return MacosKeyboardLayout.activeKeyboardLayout();
+ }
+
+ @Override
+ public KeyboardController keyboard() {
+ return keyboard;
+ }
+
+ @Override
+ public 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) {
+ }
+
+ @Override
+ public void modeTimedOut() {
+ }
+
+ private static UnsupportedOperationException unsupported(String message) {
+ return new UnsupportedOperationException(message);
+ }
+
+ private static final class UnsupportedMouseController implements MouseController {
+ @Override public void beginMove() { throw unsupported("macOS mouse movement is not implemented yet"); }
+ @Override public void endMove() { throw unsupported("macOS mouse movement is not implemented yet"); }
+ @Override public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { throw unsupported("macOS mouse movement is not implemented yet"); }
+ @Override public void synchronousMoveTo(int x, int y) { throw unsupported("macOS mouse movement is not implemented yet"); }
+ @Override public void pressLeft() { throw unsupported("macOS mouse buttons are not implemented yet"); }
+ @Override public void pressMiddle() { throw unsupported("macOS mouse buttons are not implemented yet"); }
+ @Override public void pressRight() { throw unsupported("macOS mouse buttons are not implemented yet"); }
+ @Override public void releaseLeft() { throw unsupported("macOS mouse buttons are not implemented yet"); }
+ @Override public void releaseMiddle() { throw unsupported("macOS mouse buttons are not implemented yet"); }
+ @Override public void releaseRight() { throw unsupported("macOS mouse buttons are not implemented yet"); }
+ @Override public void wheelHorizontallyBy(boolean forward, double delta) { throw unsupported("macOS scroll is not implemented yet"); }
+ @Override public void wheelVerticallyBy(boolean forward, double delta) { throw unsupported("macOS scroll is not implemented yet"); }
+ @Override public void showCursor() { }
+ @Override public void hideCursor() { logger.warn("hide-cursor is unsupported on macOS with public APIs while mousemaster is backgrounded"); }
+ }
+
+ private static final class UnsupportedOverlay implements Overlay {
+ @Override public void update(double delta) { }
+ @Override public void flushCache() { }
+ @Override public void setTopmost() { }
+ @Override public void setMessagePump(Runnable pump) { }
+ @Override public void preWarmFontStyles(Set configs) { }
+ @Override public void preWarmHintMeshWindows() { }
+ @Override public Rectangle activeWindowRectangle(double widthPct, double heightPct, int topInset, int bottomInset, int leftInset, int rightInset) { throw unsupported("macOS overlay/window geometry is not implemented yet"); }
+ @Override public void setIndicator(Indicator indicator, boolean fadeAnimationEnabled, Duration fadeAnimationDuration, boolean allowFade) { throw unsupported("macOS overlay is not implemented yet"); }
+ @Override public void hideIndicator(boolean allowFade) { }
+ @Override public void setGrid(Grid grid) { throw unsupported("macOS overlay is not implemented yet"); }
+ @Override public void hideGrid() { }
+ @Override public void setHintMesh(HintMesh hintMesh, Zoom zoom) { throw unsupported("macOS overlay is not implemented yet"); }
+ @Override public void setHintMesh(HintMesh hintMesh, Zoom zoom, boolean hintMatch) { throw unsupported("macOS overlay is not implemented yet"); }
+ @Override public void hideHintMesh() { }
+ @Override public void animateHintMatch(Hint hint) { throw unsupported("macOS overlay is not implemented yet"); }
+ @Override public void setZoom(Zoom zoom) { throw unsupported("macOS zoom is not implemented yet"); }
+ @Override public void startScreenshotZoomAnimation(Rectangle screenRect, Zoom beginZoom) { throw unsupported("macOS zoom is not implemented yet"); }
+ @Override public void updateScreenshotZoom(Zoom zoom) { throw unsupported("macOS zoom is not implemented yet"); }
+ @Override public void endScreenshotZoomAnimation(Zoom finalZoom) { throw unsupported("macOS zoom is not implemented yet"); }
+ @Override public boolean waitForZoomBeforeRepainting() { return false; }
+ @Override public void setWaitForZoomBeforeRepainting(boolean value) { }
+ }
+
+ private static final class UnsupportedUiAutomation implements UiAutomation {
+ @Override
+ public Future> startFindInteractiveUiElements() {
+ return CompletableFuture.completedFuture(List.of());
+ }
+ }
+}
diff --git a/src/main/java/mousemaster/platform/macos/MacosScreens.java b/src/main/java/mousemaster/platform/macos/MacosScreens.java
new file mode 100644
index 00000000..da0581d1
--- /dev/null
+++ b/src/main/java/mousemaster/platform/macos/MacosScreens.java
@@ -0,0 +1,14 @@
+package mousemaster.platform.macos;
+
+import mousemaster.Screen;
+import mousemaster.platform.Screens;
+
+import java.util.Set;
+
+public class MacosScreens implements Screens {
+
+ @Override
+ public Set findScreens() {
+ throw new UnsupportedOperationException("macOS display enumeration is not implemented yet");
+ }
+}
diff --git a/src/main/java/mousemaster/platform/windows/WindowsKeyboardController.java b/src/main/java/mousemaster/platform/windows/WindowsKeyboardController.java
index 3dc54f89..648bed48 100644
--- a/src/main/java/mousemaster/platform/windows/WindowsKeyboardController.java
+++ b/src/main/java/mousemaster/platform/windows/WindowsKeyboardController.java
@@ -301,7 +301,7 @@ private void sendInputKeys(List moves, boolean triggerKeyR
WinUser.INPUT[] pInputs =
(WinUser.INPUT[]) new WinUser.INPUT().toArray(moves.size());
if (moves.stream()
- .map(move -> WindowsVirtualKey.windowsVirtualKeyFromKey(move.key(),
+ .map(move -> WindowsKeys.windowsVirtualKeyFromKey(move.key(),
activeKeyboardLayout))
.anyMatch(Objects::isNull)) {
// Happens when a macro output contains a key not in the active keyboard layout.
@@ -310,7 +310,7 @@ private void sendInputKeys(List moves, boolean triggerKeyR
for (int moveIndex = 0; moveIndex < moves.size(); moveIndex++) {
ResolvedKeyMacroMove move = moves.get(moveIndex);
WindowsVirtualKey windowsVirtualKey =
- WindowsVirtualKey.windowsVirtualKeyFromKey(move.key(),
+ WindowsKeys.windowsVirtualKeyFromKey(move.key(),
activeKeyboardLayout);
// Key already pressed.
if (move.press()) {
diff --git a/src/main/java/mousemaster/platform/windows/WindowsKeys.java b/src/main/java/mousemaster/platform/windows/WindowsKeys.java
new file mode 100644
index 00000000..3fd6f9df
--- /dev/null
+++ b/src/main/java/mousemaster/platform/windows/WindowsKeys.java
@@ -0,0 +1,169 @@
+package mousemaster.platform.windows;
+
+import mousemaster.*;
+
+import com.sun.jna.Native;
+import com.sun.jna.Pointer;
+import com.sun.jna.platform.win32.User32;
+import com.sun.jna.platform.win32.WinDef;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Windows-specific keyboard layout and event translation.
+ *
+ * Shared code may carry Windows key identifiers as data, but all Win32 calls and
+ * low-level event interpretation stay here so the rest of the application can
+ * remain platform-neutral.
+ */
+public class WindowsKeys {
+
+ private static final Logger logger = LoggerFactory.getLogger(WindowsKeys.class);
+
+ private static KeyboardLayout lastPolledActiveKeyboardLayout;
+ private static int keyboardLayoutSeenCount;
+ private static boolean lastActiveKeyboardLayoutFailed;
+
+ /**
+ * When changing the layout with win + space:
+ * when opening the Win start menu, the layout of that hwnd would be the old layout for
+ * a few milliseconds. The workaround here waits for the layout to show up twice before
+ * confirming it has changed.
+ */
+ public static KeyboardLayout activeKeyboardLayout() {
+ KeyboardLayout foregroundWindowKeyboardLayout = foregroundWindowKeyboardLayout();
+ if (foregroundWindowKeyboardLayout != null) {
+ if (!foregroundWindowKeyboardLayout.equals(lastPolledActiveKeyboardLayout)) {
+ if (lastPolledActiveKeyboardLayout != null && keyboardLayoutSeenCount++ < 2)
+ return lastPolledActiveKeyboardLayout;
+ // New layout confirmed.
+ keyboardLayoutSeenCount = 0;
+ WinDef.HWND hwnd = User32.INSTANCE.GetForegroundWindow();
+ String hwndString = hwnd == null ? null :
+ String.format("0x%X", Pointer.nativeValue(hwnd.getPointer()));
+ logger.trace("Found foreground window's keyboard layout for hwnd " + hwndString + ": " +
+ foregroundWindowKeyboardLayout);
+ }
+ lastPolledActiveKeyboardLayout = foregroundWindowKeyboardLayout;
+ lastActiveKeyboardLayoutFailed = false;
+ return foregroundWindowKeyboardLayout;
+ }
+ // When changing the active window, the foreground window may be null for a short period of time (?).
+ if (lastPolledActiveKeyboardLayout != null) {
+ if (!lastActiveKeyboardLayoutFailed)
+ logger.trace(
+ "Unable to find the foreground window's keyboard layout, using last known keyboard layout " +
+ lastPolledActiveKeyboardLayout);
+ lastActiveKeyboardLayoutFailed = true;
+ return lastPolledActiveKeyboardLayout;
+ }
+ KeyboardLayout startupKeyboardLayout = startupKeyboardLayout();
+ if (!lastActiveKeyboardLayoutFailed)
+ logger.trace(
+ "Unable to find the foreground window's keyboard layout, using start up keyboard layout " +
+ startupKeyboardLayout);
+ lastPolledActiveKeyboardLayout = startupKeyboardLayout;
+ lastActiveKeyboardLayoutFailed = true;
+ return startupKeyboardLayout;
+ }
+
+ private static int lastFailedGetKeyboardLayoutThreadId = -1;
+
+ private static WinDef.HKL foregroundWindowHkl() {
+ WinDef.HWND hwnd = User32.INSTANCE.GetForegroundWindow();
+ if (hwnd == null) {
+ // GetForegroundWindow does not set the last error value.
+ logger.trace("GetForegroundWindow failed");
+ }
+ else {
+ int thread = User32.INSTANCE.GetWindowThreadProcessId(hwnd, null);
+ if (thread == 0) {
+ logger.error("GetWindowThreadProcessId failed: " + Integer.toHexString(
+ Native.getLastError()));
+ }
+ else {
+ WinDef.HKL hkl = User32.INSTANCE.GetKeyboardLayout(thread);
+ if (hkl == null) {
+ if (lastFailedGetKeyboardLayoutThreadId != thread) {
+ // GetKeyboardLayout does not set the last error value.
+ logger.error("GetKeyboardLayout failed");
+ // Avoid flooding the logs with the same error.
+ lastFailedGetKeyboardLayoutThreadId = thread;
+ }
+ }
+ else {
+ lastFailedGetKeyboardLayoutThreadId = -1;
+ }
+ return hkl;
+ }
+ }
+ return null;
+ }
+
+ private static KeyboardLayout foregroundWindowKeyboardLayout() {
+ WinDef.HKL hkl = foregroundWindowHkl();
+ if (hkl != null) {
+ // The mousemaster.exe command line window does not handle the WM_INPUTLANGCHANGE message.
+ // Therefore, when the user changes the layout, the command line window keeps the old layout.
+ // We call ActivateKeyboardLayout to change the layout of the command line window.
+ ExtendedUser32.INSTANCE.ActivateKeyboardLayout(hkl, 0);
+ int languageIdentifier = hkl.getLanguageIdentifier();
+ KeyboardLayout keyboardLayout = KeyboardLayout.keyboardLayoutByIdentifier.get(
+ String.format("%08X", languageIdentifier));
+// logger.debug("Found active window keyboard layout: " + keyboardLayout);
+ return keyboardLayout;
+ }
+ return null;
+ }
+
+ private static KeyboardLayout startupKeyboardLayout() {
+ // GetKeyboardLayoutName returns the layout at the time of when the app was started.
+ // If the system layout is changed after the app is started, GetKeyboardLayoutName
+ // still returns the old layout.
+ char[] nameBuffer = new char[User32.KL_NAMELENGTH];
+ User32.INSTANCE.GetKeyboardLayoutName(nameBuffer);
+ int nameLength = nameBuffer.length;
+ for (int i = 0; i < nameBuffer.length; i++) {
+ if (nameBuffer[i] == 0) {
+ nameLength = i;
+ break;
+ }
+ }
+ return KeyboardLayout.keyboardLayoutByIdentifier.get(
+ new String(nameBuffer, 0, nameLength));
+ }
+
+ public static Key keyFromWindowsEvent(WindowsVirtualKey windowsVirtualKey, int scanCode,
+ int flags, KeyboardLayout activeKeyboardLayout) {
+ if (scanCode == 0) {
+ // Injected key event have scanCode 0.
+ return activeKeyboardLayout().keyFromVirtualKey(windowsVirtualKey);
+ }
+ // When pressing rightctrl the scanCode should be E01D but is 1D (which is leftctrl's scanCode).
+ // rightctrl:
+ // Received key event: vkCode = 0xa3 (VK_RCONTROL), scanCode = 0x1d, flags = 0x1, wParam = WM_KEYDOWN
+ // leftctrl:
+ // Received key event: vkCode = 0xa2 (VK_LCONTROL), scanCode = 0x1d, flags = 0x0, wParam = WM_KEYDOWN
+ // For rightshift, flag is 1 but it is not an extended key (scanCode is not E036 and really is 36):
+ // Received key event: vkCode = 0xa1 (VK_RSHIFT), scanCode = 0x36, flags = 0x1, wParam = WM_KEYDOWN
+ boolean isExtended = (flags & 0x1) != 0;
+ if (isExtended) {
+ int extendedKeyScanCode = 0xE000 | scanCode;
+ Key extendedKey = activeKeyboardLayout.keyFromScanCode(extendedKeyScanCode);
+ if (extendedKey != null)
+ return extendedKey;
+ }
+ return activeKeyboardLayout.keyFromScanCode(scanCode);
+ }
+
+ public static WindowsVirtualKey windowsVirtualKeyFromKey(Key key,
+ KeyboardLayout keyboardLayout) {
+ WindowsVirtualKey virtualKey = keyboardLayout.virtualKey(key);
+ if (virtualKey == null) {
+ logger.debug("Unable to map key " + key + " to a Windows virtual key using " +
+ keyboardLayout);
+ }
+ return virtualKey;
+ }
+
+}
diff --git a/src/main/java/mousemaster/platform/windows/WindowsMain.java b/src/main/java/mousemaster/platform/windows/WindowsMain.java
index d3b9ec23..a2aec8cf 100644
--- a/src/main/java/mousemaster/platform/windows/WindowsMain.java
+++ b/src/main/java/mousemaster/platform/windows/WindowsMain.java
@@ -1,78 +1,19 @@
package mousemaster.platform.windows;
-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 mousemaster.ApplicationLauncher;
import java.io.IOException;
-import java.io.InputStream;
-import java.util.Properties;
public class WindowsMain {
- private static final Logger logger = LoggerFactory.getLogger(WindowsMain.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 = WindowsMain.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 + ")");
- 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());
- }
+ ApplicationLauncher.run(args, WindowsMain::createPlatform);
}
- private static Platform createPlatform(boolean multipleInstancesAllowed,
- boolean keyRegurgitationEnabled,
- boolean pauseOnError) {
- try {
- return new WindowsPlatform(multipleInstancesAllowed, keyRegurgitationEnabled);
- } catch (Exception e) {
- MousemasterApplication.shutdownAfterException(e, null, false, pauseOnError);
- }
- return null;
+ private static WindowsPlatform createPlatform(ApplicationOptions options) {
+ return new WindowsPlatform(options.multipleInstancesAllowed(),
+ options.keyRegurgitationEnabled());
}
}
diff --git a/src/main/java/mousemaster/platform/windows/WindowsPlatform.java b/src/main/java/mousemaster/platform/windows/WindowsPlatform.java
index 7d20f0a9..26431d77 100644
--- a/src/main/java/mousemaster/platform/windows/WindowsPlatform.java
+++ b/src/main/java/mousemaster/platform/windows/WindowsPlatform.java
@@ -262,6 +262,11 @@ public void shutdown() {
logger.trace("Released single instance mutex");
}
+ @Override
+ public QtManager.DeploymentStrategy qtDeploymentStrategy() {
+ return QtManager.DeploymentStrategy.WINDOWS_EXTRACTED_RESOURCES;
+ }
+
@Override
public KeyRegurgitator keyRegurgitator() {
return keyRegurgitator;
@@ -274,7 +279,7 @@ public Clock clock() {
@Override
public KeyboardLayout activeKeyboardLayout() {
- return WindowsVirtualKey.activeKeyboardLayout();
+ return WindowsKeys.activeKeyboardLayout();
}
@Override
@@ -333,7 +338,7 @@ private void sanityCheckCurrentlyPressedKeys(double delta) {
if (pressDuration.get() < 10)
continue;
WindowsVirtualKey windowsVirtualKey =
- WindowsVirtualKey.windowsVirtualKeyFromKey(key,
+ WindowsKeys.windowsVirtualKeyFromKey(key,
keyboard.activeKeyboardLayout);
if (windowsVirtualKey == null) {
// Can be null if key was added to currentlyPressedNotEatenKeys
@@ -570,7 +575,7 @@ private KeyEvent buildKeyEvent(WinUser.KBDLLHOOKSTRUCT info, WinDef.WPARAM wPara
// Consider altgr's leftctrl and altgr's rightalt as the same key: Key.rightalt.
key = Key.rightalt;
else
- key = WindowsVirtualKey.keyFromWindowsEvent(
+ key = WindowsKeys.keyFromWindowsEvent(
WindowsVirtualKey.values.get(info.vkCode), info.scanCode, info.flags,
keyboard.activeKeyboardLayout);
if (key == null)
diff --git a/src/main/resources/META-INF/native-image/reflect-config.json b/src/main/resources/META-INF/native-image/reflect-config.json
index 23952bd5..e54ede5a 100644
--- a/src/main/resources/META-INF/native-image/reflect-config.json
+++ b/src/main/resources/META-INF/native-image/reflect-config.json
@@ -863,7 +863,7 @@
{
"name":"mousemaster.KeyboardLayout$KeyboardLayoutKey",
"allDeclaredFields":true,
- "methods":[{"name":"","parameterTypes":["int","mousemaster.platform.windows.WindowsVirtualKey","mousemaster.Key","java.lang.String","java.lang.String"] }, {"name":"key","parameterTypes":[] }, {"name":"name","parameterTypes":[] }, {"name":"scanCode","parameterTypes":[] }, {"name":"text","parameterTypes":[] }, {"name":"virtualKey","parameterTypes":[] }]
+ "methods":[{"name":"","parameterTypes":["int","mousemaster.WindowsVirtualKey","mousemaster.Key","java.lang.String","java.lang.String"] }, {"name":"key","parameterTypes":[] }, {"name":"name","parameterTypes":[] }, {"name":"scanCode","parameterTypes":[] }, {"name":"text","parameterTypes":[] }, {"name":"virtualKey","parameterTypes":[] }]
},
{
"name":"mousemaster.platform.windows.Magnification$MAGTRANSFORM",
@@ -1057,7 +1057,7 @@
"fields":[{"name":"OPTIONS"}, {"name":"STRING_ENCODING"}, {"name":"STRUCTURE_ALIGNMENT"}, {"name":"TYPE_MAPPER"}]
},
{
- "name":"mousemaster.platform.windows.WindowsVirtualKey",
+ "name":"mousemaster.WindowsVirtualKey",
"allDeclaredFields":true
},
{
diff --git a/src/test/java/mousemaster/KeyboardLayoutOsIdentifierTest.java b/src/test/java/mousemaster/KeyboardLayoutOsIdentifierTest.java
new file mode 100644
index 00000000..f8b14265
--- /dev/null
+++ b/src/test/java/mousemaster/KeyboardLayoutOsIdentifierTest.java
@@ -0,0 +1,33 @@
+package mousemaster;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class KeyboardLayoutOsIdentifierTest {
+
+ @Test
+ void exposesOsIdentifiersAsBoundaryData() {
+ KeyboardLayout layout = new KeyboardLayout("test", "Test", "test", "test",
+ List.of(new KeyboardLayout.KeyboardLayoutKey(30, WindowsVirtualKey.VK_A,
+ Key.ofCharacter("a"), "a", "A"),
+ new KeyboardLayout.KeyboardLayoutKey(42, WindowsVirtualKey.VK_LSHIFT,
+ Key.leftshift, null, "Left Shift")));
+
+ assertEquals(Key.ofCharacter("a"), layout.keyFromScanCode(30));
+ assertEquals(Key.ofCharacter("a"), layout.keyFromVirtualKey(WindowsVirtualKey.VK_A));
+ assertEquals(30, layout.scanCode(Key.ofCharacter("a")));
+ assertEquals(WindowsVirtualKey.VK_LSHIFT, layout.virtualKey(Key.leftshift));
+ }
+
+ @Test
+ void bundledLayoutsStillExposeWindowsIdentifiersAfterJsonLoad() {
+ KeyboardLayout usQwerty = KeyboardLayout.keyboardLayoutByShortName.get("us-qwerty");
+
+ assertNotNull(usQwerty);
+ assertEquals(Key.esc, usQwerty.keyFromVirtualKey(WindowsVirtualKey.VK_ESCAPE));
+ assertEquals(WindowsVirtualKey.VK_SPACE, usQwerty.virtualKey(Key.space));
+ }
+}
diff --git a/src/test/java/mousemaster/PlatformBoundaryTest.java b/src/test/java/mousemaster/PlatformBoundaryTest.java
new file mode 100644
index 00000000..f81b38bb
--- /dev/null
+++ b/src/test/java/mousemaster/PlatformBoundaryTest.java
@@ -0,0 +1,69 @@
+package mousemaster;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PlatformBoundaryTest {
+
+ private static final Map> forbiddenPlatformReferencesByPackage =
+ Map.of(
+ "windows", List.of(
+ "import com.sun.jna.platform.win32",
+ "import mousemaster.platform.windows",
+ "mousemaster.platform.windows."
+ ),
+ "macos", List.of(
+ "import mousemaster.platform.macos",
+ "mousemaster.platform.macos."
+ )
+ );
+
+ @Test
+ void platformImplementationTypesStayInsideTheirPlatformPackage() throws IOException {
+ Path sourceRoot = Path.of("src/main/java").toAbsolutePath();
+ List violations;
+ try (var paths = Files.walk(sourceRoot)) {
+ violations = paths.filter(path -> path.toString().endsWith(".java"))
+ .flatMap(path -> platformBoundaryViolations(sourceRoot, path).stream())
+ .sorted(Comparator.comparing(violation -> violation.path().toString()))
+ .toList();
+ }
+
+ assertTrue(violations.isEmpty(), "Platform implementation dependencies outside their owning package: " +
+ violations);
+ }
+
+ private static List platformBoundaryViolations(Path sourceRoot, Path path) {
+ try {
+ Path relativePath = sourceRoot.relativize(path);
+ String source = Files.readString(path);
+ return forbiddenPlatformReferencesByPackage.entrySet()
+ .stream()
+ .filter(entry -> !relativePath.startsWith(
+ Path.of("mousemaster/platform/" +
+ entry.getKey())))
+ .flatMap(entry -> entry.getValue()
+ .stream()
+ .filter(source::contains)
+ .map(reference ->
+ new Violation(
+ relativePath,
+ entry.getKey(),
+ reference)))
+ .toList();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private record Violation(Path path, String platform, String reference) {
+ }
+}