diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f76f33a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: Test + run: sh mvnw test + shell: bash diff --git a/pom.xml b/pom.xml index ba1864dd..6a0ebb8c 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,7 @@ mousemaster mousemaster.platform.windows.WindowsMain 5.13.0 + 6.8.2 @@ -53,12 +54,7 @@ io.qtjambi qtjambi - 6.8.2 - - - io.qtjambi - qtjambi-native-windows-x64 - 6.8.2 + ${qtjambi.version} com.google.code.gson @@ -80,6 +76,42 @@ + + windows + + + windows + + + + mousemaster.platform.windows.WindowsMain + + + + io.qtjambi + qtjambi-native-windows-x64 + ${qtjambi.version} + + + + + macos + + + mac + + + + mousemaster.platform.macos.MacosMain + + + + io.qtjambi + qtjambi-native-macos + ${qtjambi.version} + + + native diff --git a/src/main/java/mousemaster/ApplicationLauncher.java b/src/main/java/mousemaster/ApplicationLauncher.java new file mode 100644 index 00000000..5336122a --- /dev/null +++ b/src/main/java/mousemaster/ApplicationLauncher.java @@ -0,0 +1,87 @@ +package mousemaster; + +import com.sun.jna.Native; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public final class ApplicationLauncher { + + private static final Logger logger = LoggerFactory.getLogger(ApplicationLauncher.class); + + @FunctionalInterface + public interface PlatformFactory { + Platform create(ApplicationOptions options) throws Exception; + } + + private ApplicationLauncher() { + } + + public static void run(String[] args, PlatformFactory platformFactory) + 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(); + Version version = readVersion(); + if (options.showVersion()) { + System.out.println("mousemaster v" + version.version() + " (" + + 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(platformFactory, options); + logger.info("mousemaster v" + version.version() + " (" + 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()); + } + } + + private static Platform createPlatform(PlatformFactory platformFactory, + ApplicationOptions options) { + try { + return platformFactory.create(options); + } catch (Exception e) { + MousemasterApplication.shutdownAfterException(e, null, false, + options.pauseOnError()); + } + return null; + } + + private static Version readVersion() throws IOException { + try (InputStream versionInputStream = ApplicationLauncher.class.getClassLoader() + .getResourceAsStream( + "application.properties")) { + Properties versionProp = new Properties(); + versionProp.load(versionInputStream); + return new Version(versionProp.getProperty("version"), + versionProp.getProperty("commitId")); + } + } + + private record Version(String version, String commitId) { + } +} diff --git a/src/main/java/mousemaster/KbdlayoutinfoParser.java b/src/main/java/mousemaster/KbdlayoutinfoParser.java index 02bdb14a..027c7870 100644 --- a/src/main/java/mousemaster/KbdlayoutinfoParser.java +++ b/src/main/java/mousemaster/KbdlayoutinfoParser.java @@ -2,7 +2,6 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import mousemaster.platform.windows.WindowsVirtualKey; import org.jsoup.Jsoup; import org.jsoup.select.Elements; import org.slf4j.Logger; diff --git a/src/main/java/mousemaster/KeyboardLayout.java b/src/main/java/mousemaster/KeyboardLayout.java index 892265ef..2f571a34 100644 --- a/src/main/java/mousemaster/KeyboardLayout.java +++ b/src/main/java/mousemaster/KeyboardLayout.java @@ -2,13 +2,13 @@ import com.google.gson.*; import com.google.gson.reflect.TypeToken; -import mousemaster.platform.windows.WindowsVirtualKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.InputStreamReader; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -74,6 +74,9 @@ public record KeyboardLayoutKey(int scanCode, WindowsVirtualKey virtualKey, Key private final String driverName; private final String shortName; private final List keys; + private transient Map keyByScanCode; + private transient Map keyByVirtualKey; + private transient Map layoutKeyByKey; public KeyboardLayout(String identifier, String displayName, String driverName, String shortName, List keys) { @@ -109,35 +112,43 @@ public boolean containsKey(Key key) { } public Key keyFromScanCode(int scanCode) { - for (KeyboardLayoutKey keyboardLayoutKey : keys) { - if (keyboardLayoutKey.scanCode == scanCode) - return keyboardLayoutKey.key(); - } - return null; + buildLookupMaps(); + return keyByScanCode.get(scanCode); } public Key keyFromVirtualKey(WindowsVirtualKey virtualKey) { - for (KeyboardLayoutKey keyboardLayoutKey : keys) { - if (keyboardLayoutKey.virtualKey == virtualKey) - return keyboardLayoutKey.key(); - } - return null; + buildLookupMaps(); + return keyByVirtualKey.get(virtualKey); } public int scanCode(Key key) { - for (KeyboardLayoutKey keyboardLayoutKey : keys) { - if (keyboardLayoutKey.key.equals(key)) - return keyboardLayoutKey.scanCode(); - } - return -1; + buildLookupMaps(); + KeyboardLayoutKey layoutKey = layoutKeyByKey.get(key); + return layoutKey == null ? -1 : layoutKey.scanCode(); } public WindowsVirtualKey virtualKey(Key key) { + buildLookupMaps(); + KeyboardLayoutKey layoutKey = layoutKeyByKey.get(key); + return layoutKey == null ? null : layoutKey.virtualKey(); + } + + private void buildLookupMaps() { + if (layoutKeyByKey != null) + return; + Map byScanCode = new HashMap<>(); + Map byVirtualKey = new HashMap<>(); + Map byKey = new HashMap<>(); for (KeyboardLayoutKey keyboardLayoutKey : keys) { - if (keyboardLayoutKey.key.equals(key)) - return keyboardLayoutKey.virtualKey(); + byScanCode.putIfAbsent(keyboardLayoutKey.scanCode(), keyboardLayoutKey.key()); + if (keyboardLayoutKey.virtualKey() != null) + byVirtualKey.putIfAbsent(keyboardLayoutKey.virtualKey(), + keyboardLayoutKey.key()); + byKey.putIfAbsent(keyboardLayoutKey.key(), keyboardLayoutKey); } - return null; + keyByScanCode = Collections.unmodifiableMap(byScanCode); + keyByVirtualKey = Collections.unmodifiableMap(byVirtualKey); + layoutKeyByKey = Collections.unmodifiableMap(byKey); } @Override diff --git a/src/main/java/mousemaster/Mousemaster.java b/src/main/java/mousemaster/Mousemaster.java index fdf60648..c7bc1790 100644 --- a/src/main/java/mousemaster/Mousemaster.java +++ b/src/main/java/mousemaster/Mousemaster.java @@ -32,7 +32,7 @@ public Mousemaster(Path configurationPath, Platform platform) throws IOException this.configurationPath = configurationPath; this.platform = platform; this.activeKeyboardLayout = platform.activeKeyboardLayout(); - QtManager.initialize(); + QtManager.initialize(platform.qtDeploymentStrategy()); loadConfiguration(true); watchService = FileSystems.getDefault().newWatchService(); configurationPath.toAbsolutePath() diff --git a/src/main/java/mousemaster/Platform.java b/src/main/java/mousemaster/Platform.java index b1f30bce..ceca0cc9 100644 --- a/src/main/java/mousemaster/Platform.java +++ b/src/main/java/mousemaster/Platform.java @@ -24,6 +24,8 @@ void reset(MouseManager mouseManager, KeyboardManager keyboardManager, void shutdown(); + QtManager.DeploymentStrategy qtDeploymentStrategy(); + KeyRegurgitator keyRegurgitator(); Clock clock(); diff --git a/src/main/java/mousemaster/QtManager.java b/src/main/java/mousemaster/QtManager.java index 8a3fe6ee..8ea01d8c 100644 --- a/src/main/java/mousemaster/QtManager.java +++ b/src/main/java/mousemaster/QtManager.java @@ -18,6 +18,12 @@ public class QtManager { private static final Logger logger = LoggerFactory.getLogger(QtManager.class.getName()); + private static boolean initialized; + + public enum DeploymentStrategy { + WINDOWS_EXTRACTED_RESOURCES, + QTJAMBI_DEFAULT + } private static final List 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) { + } +}