diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index fed5b646..69a7e334 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,7 +9,8 @@ on: env: RUST_VERSION: 1.91.0 # the same as in rust-toolchain -# ACTIONS_STEP_DEBUG: true + ACTIONS_STEP_DEBUG: true + CARGO_INCREMENTAL: 1 jobs: build_all: @@ -34,6 +35,10 @@ jobs: sudo apt update sudo apt install libwayland-dev libxkbcommon-dev + - name: Install Keyboard Layouts + if: ${{ startsWith(matrix.targets.os, 'macos') }} + run: ./scripts/macos_install_input_sources.sh + - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: @@ -53,10 +58,10 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Lint with Gradle run: ./gradlew lint + - name: Test with Gradle + run: ./gradlew test --info - name: Build with Gradle run: ./gradlew build - - name: Test with Gradle - run: ./gradlew test --rerun - name: Publish Test Report diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index a3541cb2..07512511 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -47,6 +47,10 @@ jobs: sudo apt install libwayland-dev libxkbcommon-dev shell: bash + - name: Install Keyboard Layouts + if: ${{ startsWith(matrix.targets.os, 'macos') }} + run: ./scripts/macos_install_input_sources.sh + - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: diff --git a/kotlin-desktop-toolkit/build.gradle.kts b/kotlin-desktop-toolkit/build.gradle.kts index 125ab24b..361479e0 100644 --- a/kotlin-desktop-toolkit/build.gradle.kts +++ b/kotlin-desktop-toolkit/build.gradle.kts @@ -465,6 +465,7 @@ tasks.test { } testLogging { + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL events("failed") events("passed") events("skipped") diff --git a/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Application.kt b/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Application.kt index a7dd72e6..97ee4da1 100644 --- a/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Application.kt +++ b/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Application.kt @@ -178,6 +178,39 @@ public object Application { } } + public fun currentInputSource(): String? { + val layout = ffiDownCall { + desktop_macos_h.application_current_input_source() + } + if (layout == MemorySegment.NULL) return null + return try { + layout.getUtf8String(0) + } finally { + ffiDownCall { desktop_macos_h.string_drop(layout) } + } + } + + public fun listInputSources(): List { + return ffiDownCall { + Arena.ofConfined().use { arena -> + val result = desktop_macos_h.application_list_input_sources(arena) + try { + listOfStringsFromNative(result) + } finally { + ffiDownCall { desktop_macos_h.string_array_drop(result) } + } + } + } + } + + public fun chooseInputSource(sourceId: String): Boolean { + return ffiDownCall { + Arena.ofConfined().use { arena -> + desktop_macos_h.application_choose_input_source(arena.allocateUtf8String(sourceId)) + } + } + } + private var isSafeToQuit: () -> Boolean = { true } // called from native diff --git a/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Keyboard.kt b/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Keyboard.kt index 65a3a797..b5cd46a7 100644 --- a/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Keyboard.kt +++ b/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Keyboard.kt @@ -16,7 +16,7 @@ package org.jetbrains.desktop.macos * keycode. */ @JvmInline -public value class KeyCode internal constructor(private val value: Short) { +public value class KeyCode internal constructor(internal val value: Short) { @Suppress("MemberVisibilityCanBePrivate") public companion object { public val ANSI_A: KeyCode = KeyCode(0) @@ -355,6 +355,7 @@ public object CodepointConstants { public const val LineSeparatorCharacter: Int = 0x2028 public const val ParagraphSeparatorCharacter: Int = 0x2029 + // Unicode private use area public const val UpArrowFunctionKey: Int = 0xF700 public const val DownArrowFunctionKey: Int = 0xF701 public const val LeftArrowFunctionKey: Int = 0xF702 diff --git a/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Robot.kt b/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Robot.kt new file mode 100644 index 00000000..8831e158 --- /dev/null +++ b/kotlin-desktop-toolkit/src/main/kotlin/org/jetbrains/desktop/macos/Robot.kt @@ -0,0 +1,24 @@ +package org.jetbrains.desktop.macos + +import org.jetbrains.desktop.macos.generated.desktop_macos_h + +public class Robot : AutoCloseable { + + init { + ffiDownCall { + desktop_macos_h.robot_initialize() + } + } + + public fun emulateKeyboardEvent(key: KeyCode, isKeyDown: Boolean) { + ffiDownCall { + desktop_macos_h.emulate_keyboard_event(key.value, isKeyDown) + } + } + + override fun close() { + ffiDownCall { + desktop_macos_h.robot_deinitialize() + } + } +} diff --git a/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/KeyboardTest.kt b/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/KeyboardTest.kt new file mode 100644 index 00000000..1ca066b1 --- /dev/null +++ b/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/KeyboardTest.kt @@ -0,0 +1,441 @@ +package org.jetbrains.desktop.macos.tests + +import org.jetbrains.desktop.macos.Application +import org.jetbrains.desktop.macos.Event +import org.jetbrains.desktop.macos.KeyCode +import org.jetbrains.desktop.macos.KeyModifiersSet +import org.jetbrains.desktop.macos.Logger +import org.jetbrains.desktop.macos.LogicalPoint +import org.jetbrains.desktop.macos.Robot +import org.jetbrains.desktop.macos.Window +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import java.util.Locale.getDefault +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals + +@EnabledOnOs(OS.MAC) +class KeyboardTest : KDTApplicationTestBase() { + + fun Set.toModifiersSet(): KeyModifiersSet { + return KeyModifiersSet.create( + shift = contains(KeyCode.Shift) || contains(KeyCode.RightShift), + control = contains(KeyCode.Control) || contains(KeyCode.RightControl), + option = contains(KeyCode.Option) || contains(KeyCode.RightOption), + command = contains(KeyCode.Command) || contains(KeyCode.RightCommand), + capsLock = contains(KeyCode.CapsLock), + numericPad = false, + help = false, + function = false, + ) + } + + fun pressOneKeyAndAwaitEvent(keyCode: KeyCode, typed: String, key: String, keyWithModifiers: String, modifiers: Set) { + val modifiersSet = modifiers.toModifiersSet() + + try { + for (modifier in modifiers) { + ui { + robot.emulateKeyboardEvent(modifier, true) + } + } + + ui { + robot.emulateKeyboardEvent(keyCode, true) + } + ui { + robot.emulateKeyboardEvent(keyCode, false) + } + + awaitEventOfType { + it.keyCode == keyCode && + it.typedCharacters == typed && + it.key == key && + it.keyWithModifiers == keyWithModifiers && + it.modifiers == modifiersSet + } + awaitEventOfType { + it.keyCode == keyCode && + it.typedCharacters == typed && + it.key == key && + it.keyWithModifiers == keyWithModifiers && + it.modifiers == modifiersSet + } + } finally { + for (modifier in modifiers) { + ui { + robot.emulateKeyboardEvent(modifier, false) + } + } + } + } + + companion object { + lateinit var window: Window + lateinit var robot: Robot + + @JvmStatic + @BeforeAll + @Timeout(value = 15, unit = TimeUnit.SECONDS) + fun init() { + Logger.info { "KeyboardTest INIT STARTED" } + robot = ui { Robot() } + window = ui { + val window = Window.create(origin = LogicalPoint(100.0, 200.0), title = "Keyboard Test Window") + Logger.info { "KeyboardTest create window with ID: ${window.windowId()}" } + window + } + ui { + window.makeKeyAndOrderFront() + } + awaitEventOfType { it.windowId == window.windowId() && it.isVisible } + + if (!window.isKey) { + ui { + window.makeKeyAndOrderFront() + } + Logger.info { "KeyboardTest before Window focused" } + awaitEventOfType { it.isKeyWindow } + Logger.info { "KeyboardTest Window focused" } + } + assert(ui { Application.chooseInputSource("com.apple.keylayout.ABC") }) { "Failed to choose ABC keyboard layout" } + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + Logger.info { "KeyboardTest INIT FINISHED" } + } + + @JvmStatic + @AfterAll + @Timeout(value = 15, unit = TimeUnit.SECONDS) + fun destroy() { + Logger.info { "KeyboardTest DESTROY STARTED" } + ui { + window.close() + } + eventHandler = null + ui { robot.close() } + Logger.info { "KeyboardTest DESTROY FINISHED" } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersNoModifiersTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + ansiLetters.forEach { (keyCode, letter) -> + pressOneKeyAndAwaitEvent(keyCode, typed = letter, key = letter, keyWithModifiers = letter, modifiers = emptySet()) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithShiftTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Shift) + ansiLetters.forEach { (keyCode, letter) -> + val uppercaseLetter = letter.uppercase(getDefault()) + pressOneKeyAndAwaitEvent( + keyCode, + typed = uppercaseLetter, + key = letter, + keyWithModifiers = uppercaseLetter, + modifiers = modifiers, + ) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithCommandTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Command) + ansiLetters.forEach { (keyCode, letter) -> + pressOneKeyAndAwaitEvent(keyCode, typed = letter, key = letter, keyWithModifiers = letter, modifiers = modifiers) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithCommandShiftTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Command, KeyCode.Shift) + for ((keyCode, letter) in ansiLetters) { + if (keyCode == KeyCode.ANSI_Q) { + continue // Close all apps and quit + } + pressOneKeyAndAwaitEvent(keyCode, typed = letter, key = letter, keyWithModifiers = letter, modifiers = modifiers) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithCommandControlTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Command, KeyCode.Control) + for ((keyCode, letter) in ansiLetters) { + if (keyCode == KeyCode.ANSI_D) { + continue // Reserved by Dictionary.app + } + if (keyCode == KeyCode.ANSI_Q) { + continue // Quit session + } + val keyWithModifiers: String = controlLayer[keyCode]!! + pressOneKeyAndAwaitEvent( + keyCode, + typed = keyWithModifiers, + key = letter, + keyWithModifiers = keyWithModifiers, + modifiers = modifiers, + ) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithControlTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Control) + ansiLetters.forEach { (keyCode, letter) -> + val keyWithModifiers: String = controlLayer[keyCode]!! + pressOneKeyAndAwaitEvent( + keyCode, + typed = keyWithModifiers, + key = letter, + keyWithModifiers = keyWithModifiers, + modifiers = modifiers, + ) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithControlShiftTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Control, KeyCode.Shift) + ansiLetters.forEach { (keyCode, letter) -> + val keyWithModifiers: String = controlLayer[keyCode]!! + pressOneKeyAndAwaitEvent( + keyCode, + typed = keyWithModifiers, + key = letter, + keyWithModifiers = keyWithModifiers, + modifiers = modifiers, + ) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithOptionTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Option) + + ansiLetters.forEach { (keyCode, letter) -> + val keyData = optionLayer[keyCode]!! + val optionLayerLetter = keyData.letter + val typed = if (keyData.isDeadKey) { + "" + } else { + optionLayerLetter + } + pressOneKeyAndAwaitEvent(keyCode, typed = typed, key = letter, keyWithModifiers = optionLayerLetter, modifiers = modifiers) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithOptionShiftTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Option, KeyCode.Shift) + + ansiLetters.forEach { (keyCode, letter) -> + val keyData = optionLayerShifted[keyCode]!! + val optionLayerLetter = keyData.letter + val typed = if (keyData.isDeadKey) { + "" + } else { + optionLayerLetter + } + pressOneKeyAndAwaitEvent(keyCode, typed = typed, key = letter, keyWithModifiers = optionLayerLetter, modifiers = modifiers) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithOptionCommandTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Command, KeyCode.Option) + for ((keyCode, letter) in ansiLetters) { + if (keyCode == KeyCode.ANSI_D) { + continue // Is not reported on CI + } + if (keyCode == KeyCode.ANSI_N) { + continue // Global shortcut used by Arc browser + } + val keyData = optionLayer[keyCode]!! + val optionLayerLetter = keyData.letter + val keyWithModifiers = if (keyData.isDeadKey) { + keyData.deadKeyReplacement!! + } else { + optionLayerLetter + } + pressOneKeyAndAwaitEvent( + keyCode, + typed = keyWithModifiers, + key = letter, + keyWithModifiers = keyWithModifiers, + modifiers = modifiers, + ) + } + } + + // Same behavior as in Ctrl+Letter + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + fun latinLettersWithOptionControlTest() { + assertEquals("com.apple.keylayout.ABC", ui { Application.currentInputSource() }) + val modifiers = setOf(KeyCode.Control, KeyCode.Option) + ansiLetters.forEach { (keyCode, letter) -> + val keyWithModifiers: String = controlLayer[keyCode]!! + pressOneKeyAndAwaitEvent( + keyCode, + typed = keyWithModifiers, + key = letter, + keyWithModifiers = keyWithModifiers, + modifiers = modifiers, + ) + } + } + + data class KeyData( + val keyCode: KeyCode, + val letter: String, + ) + + val ansiLetters = listOf( + KeyData(KeyCode.ANSI_A, "a"), + KeyData(KeyCode.ANSI_B, "b"), + KeyData(KeyCode.ANSI_C, "c"), + KeyData(KeyCode.ANSI_D, "d"), + KeyData(KeyCode.ANSI_E, "e"), + KeyData(KeyCode.ANSI_F, "f"), + KeyData(KeyCode.ANSI_G, "g"), + KeyData(KeyCode.ANSI_H, "h"), + KeyData(KeyCode.ANSI_I, "i"), + KeyData(KeyCode.ANSI_J, "j"), + KeyData(KeyCode.ANSI_K, "k"), + KeyData(KeyCode.ANSI_L, "l"), + KeyData(KeyCode.ANSI_M, "m"), + KeyData(KeyCode.ANSI_N, "n"), + KeyData(KeyCode.ANSI_O, "o"), + KeyData(KeyCode.ANSI_P, "p"), + KeyData(KeyCode.ANSI_Q, "q"), + KeyData(KeyCode.ANSI_R, "r"), + KeyData(KeyCode.ANSI_S, "s"), + KeyData(KeyCode.ANSI_T, "t"), + KeyData(KeyCode.ANSI_U, "u"), + KeyData(KeyCode.ANSI_V, "v"), + KeyData(KeyCode.ANSI_W, "w"), + KeyData(KeyCode.ANSI_X, "x"), + KeyData(KeyCode.ANSI_Y, "y"), + KeyData(KeyCode.ANSI_Z, "z"), + ) + + data class OptionLayerKeyData( + val letter: String, + val isDeadKey: Boolean, + val deadKeyReplacement: String? = null, + ) + + val optionLayer = mapOf( + Pair(KeyCode.ANSI_A, OptionLayerKeyData("å", isDeadKey = false)), + Pair(KeyCode.ANSI_B, OptionLayerKeyData("∫", isDeadKey = false)), + Pair(KeyCode.ANSI_C, OptionLayerKeyData("ç", isDeadKey = false)), + Pair(KeyCode.ANSI_D, OptionLayerKeyData("∂", isDeadKey = false)), + Pair(KeyCode.ANSI_E, OptionLayerKeyData("´", isDeadKey = true, deadKeyReplacement = "´")), + Pair(KeyCode.ANSI_F, OptionLayerKeyData("ƒ", isDeadKey = false)), + Pair(KeyCode.ANSI_G, OptionLayerKeyData("©", isDeadKey = false)), + Pair(KeyCode.ANSI_H, OptionLayerKeyData("˙", isDeadKey = false)), + Pair(KeyCode.ANSI_I, OptionLayerKeyData("ˆ", isDeadKey = true, deadKeyReplacement = "^")), + Pair(KeyCode.ANSI_J, OptionLayerKeyData("∆", isDeadKey = false)), + Pair(KeyCode.ANSI_K, OptionLayerKeyData("˚", isDeadKey = false)), + Pair(KeyCode.ANSI_L, OptionLayerKeyData("¬", isDeadKey = false)), + Pair(KeyCode.ANSI_M, OptionLayerKeyData("µ", isDeadKey = false)), + Pair(KeyCode.ANSI_N, OptionLayerKeyData("˜", isDeadKey = true, deadKeyReplacement = "~")), + Pair(KeyCode.ANSI_O, OptionLayerKeyData("ø", isDeadKey = false)), + Pair(KeyCode.ANSI_P, OptionLayerKeyData("π", isDeadKey = false)), + Pair(KeyCode.ANSI_Q, OptionLayerKeyData("œ", isDeadKey = false)), + Pair(KeyCode.ANSI_R, OptionLayerKeyData("®", isDeadKey = false)), + Pair(KeyCode.ANSI_S, OptionLayerKeyData("ß", isDeadKey = false)), + Pair(KeyCode.ANSI_T, OptionLayerKeyData("†", isDeadKey = false)), + Pair(KeyCode.ANSI_U, OptionLayerKeyData("¨", isDeadKey = true, deadKeyReplacement = "¨")), + Pair(KeyCode.ANSI_V, OptionLayerKeyData("√", isDeadKey = false)), + Pair(KeyCode.ANSI_W, OptionLayerKeyData("∑", isDeadKey = false)), + Pair(KeyCode.ANSI_X, OptionLayerKeyData("≈", isDeadKey = false)), + Pair(KeyCode.ANSI_Y, OptionLayerKeyData("¥", isDeadKey = false)), + Pair(KeyCode.ANSI_Z, OptionLayerKeyData("Ω", isDeadKey = false)), + ) + + val optionLayerShifted = mapOf( + Pair(KeyCode.ANSI_A, OptionLayerKeyData("Å", isDeadKey = false)), + Pair(KeyCode.ANSI_B, OptionLayerKeyData("ı", isDeadKey = false)), + Pair(KeyCode.ANSI_C, OptionLayerKeyData("Ç", isDeadKey = false)), + Pair(KeyCode.ANSI_D, OptionLayerKeyData("Î", isDeadKey = false)), + Pair(KeyCode.ANSI_E, OptionLayerKeyData("´", isDeadKey = false)), + Pair(KeyCode.ANSI_F, OptionLayerKeyData("Ï", isDeadKey = false)), + Pair(KeyCode.ANSI_G, OptionLayerKeyData("˝", isDeadKey = false)), + Pair(KeyCode.ANSI_H, OptionLayerKeyData("Ó", isDeadKey = false)), + Pair(KeyCode.ANSI_I, OptionLayerKeyData("ˆ", isDeadKey = false)), + Pair(KeyCode.ANSI_J, OptionLayerKeyData("Ô", isDeadKey = false)), + Pair(KeyCode.ANSI_K, OptionLayerKeyData("\uF8FF", isDeadKey = false)), // Apple logo + Pair(KeyCode.ANSI_L, OptionLayerKeyData("Ò", isDeadKey = false)), + Pair(KeyCode.ANSI_M, OptionLayerKeyData("Â", isDeadKey = false)), + Pair(KeyCode.ANSI_N, OptionLayerKeyData("˜", isDeadKey = false)), + Pair(KeyCode.ANSI_O, OptionLayerKeyData("Ø", isDeadKey = false)), + Pair(KeyCode.ANSI_P, OptionLayerKeyData("∏", isDeadKey = false)), + Pair(KeyCode.ANSI_Q, OptionLayerKeyData("Œ", isDeadKey = false)), + Pair(KeyCode.ANSI_R, OptionLayerKeyData("‰", isDeadKey = false)), + Pair(KeyCode.ANSI_S, OptionLayerKeyData("Í", isDeadKey = false)), + Pair(KeyCode.ANSI_T, OptionLayerKeyData("ˇ", isDeadKey = false)), + Pair(KeyCode.ANSI_U, OptionLayerKeyData("¨", isDeadKey = false)), + Pair(KeyCode.ANSI_V, OptionLayerKeyData("◊", isDeadKey = false)), + Pair(KeyCode.ANSI_W, OptionLayerKeyData("„", isDeadKey = false)), + Pair(KeyCode.ANSI_X, OptionLayerKeyData("˛", isDeadKey = false)), + Pair(KeyCode.ANSI_Y, OptionLayerKeyData("Á", isDeadKey = false)), + Pair(KeyCode.ANSI_Z, OptionLayerKeyData("¸", isDeadKey = false)), + ) + + // https://chatgpt.com/share/695d443f-4260-8005-8992-3a13a00a575c + // Historically Ctrl+A or other letters used for entering control characters + val controlLayer = mapOf( + Pair(KeyCode.ANSI_A, String(intArrayOf(1), 0, 1)), // Start of Heading + Pair(KeyCode.ANSI_B, String(intArrayOf(2), 0, 1)), // Start of Text + Pair(KeyCode.ANSI_C, String(intArrayOf(3), 0, 1)), // End of Text + Pair(KeyCode.ANSI_D, String(intArrayOf(4), 0, 1)), // End of Transmission + Pair(KeyCode.ANSI_E, String(intArrayOf(5), 0, 1)), // Enquiry + Pair(KeyCode.ANSI_F, String(intArrayOf(6), 0, 1)), // Acknowledge + Pair(KeyCode.ANSI_G, String(intArrayOf(7), 0, 1)), // Bell + Pair(KeyCode.ANSI_H, String(intArrayOf(8), 0, 1)), // Backspace + Pair(KeyCode.ANSI_I, String(intArrayOf(9), 0, 1)), // Horizontal Tab + Pair(KeyCode.ANSI_J, String(intArrayOf(10), 0, 1)), // Line Feed + Pair(KeyCode.ANSI_K, String(intArrayOf(11), 0, 1)), // Vertical Tab + Pair(KeyCode.ANSI_L, String(intArrayOf(12), 0, 1)), // Form Feed + Pair(KeyCode.ANSI_M, String(intArrayOf(13), 0, 1)), // Carriage Return + Pair(KeyCode.ANSI_N, String(intArrayOf(14), 0, 1)), // Shift Out + Pair(KeyCode.ANSI_O, String(intArrayOf(15), 0, 1)), // Shift In + Pair(KeyCode.ANSI_P, String(intArrayOf(16), 0, 1)), // Data Link Escape + Pair(KeyCode.ANSI_Q, String(intArrayOf(17), 0, 1)), // Device Control 1 + Pair(KeyCode.ANSI_R, String(intArrayOf(18), 0, 1)), // Device Control 2 + Pair(KeyCode.ANSI_S, String(intArrayOf(19), 0, 1)), // Device Control 3 + Pair(KeyCode.ANSI_T, String(intArrayOf(20), 0, 1)), // Device Control 4 + Pair(KeyCode.ANSI_U, String(intArrayOf(21), 0, 1)), // Negative Acknowledge + Pair(KeyCode.ANSI_V, String(intArrayOf(22), 0, 1)), // Synchronous Idle + Pair(KeyCode.ANSI_W, String(intArrayOf(23), 0, 1)), // End of Transmission Block + Pair(KeyCode.ANSI_X, String(intArrayOf(24), 0, 1)), // Cancel + Pair(KeyCode.ANSI_Y, String(intArrayOf(25), 0, 1)), // End of Medium + Pair(KeyCode.ANSI_Z, String(intArrayOf(26), 0, 1)), // Substitute + ) +} diff --git a/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/RobotTest.kt b/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/RobotTest.kt new file mode 100644 index 00000000..0f208bae --- /dev/null +++ b/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/RobotTest.kt @@ -0,0 +1,214 @@ +package org.jetbrains.desktop.macos.tests + +import org.jetbrains.desktop.macos.Application +import org.jetbrains.desktop.macos.Event +import org.jetbrains.desktop.macos.EventHandlerResult +import org.jetbrains.desktop.macos.KeyCode +import org.jetbrains.desktop.macos.Logger +import org.jetbrains.desktop.macos.LogicalPoint +import org.jetbrains.desktop.macos.Robot +import org.jetbrains.desktop.macos.Window +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@EnabledOnOs(OS.MAC) +class RobotTest : KDTApplicationTestBase() { + + companion object { + lateinit var window: Window + lateinit var robot: Robot + + @BeforeAll + @JvmStatic + fun init() { + Logger.info { "RobotTest INIT STARTED" } + robot = ui { Robot() } + + window = ui { + val window = Window.create(origin = LogicalPoint(100.0, 200.0), title = "Robot Test Window") + Logger.info { "RobotTest create window with ID: ${window.windowId()}" } + window + } + ui { + window.makeKeyAndOrderFront() + } + awaitEventOfType { it.windowId == window.windowId() && it.isVisible } + if (!window.isKey) { + ui { + window.makeKeyAndOrderFront() + } + Logger.info { "RobotTest before Window focused" } + awaitEventOfType { it.isKeyWindow } + Logger.info { "RobotTest Window focused" } + } + + Logger.info { "RobotTest INIT FINISHED" } + } + + @AfterAll + @JvmStatic + fun destroy() { + Logger.info { "RobotTest DESTROY STARTED" } + ui { robot.close() } + ui { + window.close() + } + Logger.info { "RobotTest DESTROY FINISHED" } + } + } + + @Test + fun `robot waits until the event is delivered to os`() { + repeat(100) { + val hadCapitalA = java.util.concurrent.atomic.AtomicBoolean(false) + withEventHandler(handler = { + if (it is Event.KeyDown && it.keyCode == KeyCode.ANSI_A && it.typedCharacters == "A") { + hadCapitalA.set(true) + } + EventHandlerResult.Continue + }) { + ui { robot.emulateKeyboardEvent(KeyCode.Shift, isKeyDown = true) } + + ui { robot.emulateKeyboardEvent(KeyCode.ANSI_A, isKeyDown = true) } + + ui { robot.emulateKeyboardEvent(KeyCode.ANSI_A, isKeyDown = false) } + + ui { robot.emulateKeyboardEvent(KeyCode.Shift, isKeyDown = false) } + + ui { robot.emulateKeyboardEvent(KeyCode.ANSI_X, isKeyDown = true) } + ui { robot.emulateKeyboardEvent(KeyCode.ANSI_X, isKeyDown = false) } + awaitEventOfType { it.keyCode == KeyCode.ANSI_X } + } + assertNotNull(hadCapitalA.get()) + } + } + + @Test + fun `modifiers are correctly stacked`() { + repeat(100) { + ui { robot.emulateKeyboardEvent(KeyCode.Shift, isKeyDown = true) } + ui { robot.emulateKeyboardEvent(KeyCode.Command, isKeyDown = true) } + ui { robot.emulateKeyboardEvent(KeyCode.Option, isKeyDown = true) } + awaitEventOfType { + it.keyCode == KeyCode.Option && + it.modifiers.shift && + it.modifiers.command && + it.modifiers.option + } + + ui { robot.emulateKeyboardEvent(KeyCode.Option, isKeyDown = false) } + ui { robot.emulateKeyboardEvent(KeyCode.Command, isKeyDown = false) } + ui { robot.emulateKeyboardEvent(KeyCode.Shift, isKeyDown = false) } + awaitEventOfType { + it.keyCode == KeyCode.Shift && + !it.modifiers.shift && + !it.modifiers.command && + !it.modifiers.option + } + } + } + + @Test + fun `modifiers command option`() { + repeat(100) { + ui { robot.emulateKeyboardEvent(KeyCode.Command, isKeyDown = true) } + ui { robot.emulateKeyboardEvent(KeyCode.Option, isKeyDown = true) } + awaitEventOfType { + it.modifiers.command && + it.modifiers.option + } + + ui { robot.emulateKeyboardEvent(KeyCode.Command, isKeyDown = false) } + ui { robot.emulateKeyboardEvent(KeyCode.Option, isKeyDown = false) } + awaitEventOfType { + !it.modifiers.shift && + !it.modifiers.command && + !it.modifiers.option + } + } + } + + @Test + fun `input source test`() { + val inputSource = ui { Application.currentInputSource() } + assert(inputSource?.startsWith("com.apple.keylayout") == true) { + "$inputSource should start with 'com.apple.keylayout'" + } + } + + @Test + fun `list input sources test`() { + val inputSources = ui { Application.listInputSources() } + Logger.info { "Input sources: $inputSources" } + assert(inputSources.isNotEmpty()) { "Input sources list should not be empty" } + assert(inputSources.any { it.startsWith("com.apple.keylayout.") }) { + "Should contain at least one keyboard layout" + } + } + + @Test + fun `check that all required input sources are installed`() { + val inputSources = ui { Application.listInputSources() } + assertContains(inputSources, "com.apple.keylayout.ABC") + assertContains(inputSources, "com.apple.keylayout.Russian") + assertContains(inputSources, "com.apple.keylayout.Swedish-Pro") + assertContains(inputSources, "com.apple.keylayout.USInternational-PC") + assertContains(inputSources, "com.apple.keylayout.German") + assertContains(inputSources, "com.apple.keylayout.Serbian-Latin") + assertContains(inputSources, "com.apple.keylayout.Serbian") + assertContains(inputSources, "com.apple.keylayout.Dvorak") + assertContains(inputSources, "com.apple.keylayout.DVORAK-QWERTYCMD") + assertContains(inputSources, "com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese") + assertContains(inputSources, "com.apple.inputmethod.TCIM.Pinyin") + assertContains(inputSources, "com.apple.inputmethod.Korean.2SetKorean") + } + + @Test + fun `current input source is in the list of input sources`() { + val currentLayout = ui { Application.currentInputSource() } + val inputSources = ui { Application.listInputSources() } + assert(currentLayout != null) { "Current keyboard layout should not be null" } + assertContains(inputSources, currentLayout, "Current keyboard layout should be in the list of input sources") + } + + @Test + fun `choose input source and restore`() { + val originalLayout = ui { Application.currentInputSource() } + assertNotNull(originalLayout) + + val inputSources = ui { Application.listInputSources() } + val anotherLayout = inputSources.firstOrNull { it != originalLayout && it.startsWith("com.apple.keylayout.") } + + if (anotherLayout != null) { + val switched = ui { Application.chooseInputSource(anotherLayout) } + assert(switched) { "Failed to switch to $anotherLayout" } + + val currentAfterSwitch = ui { Application.currentInputSource() } + assertEquals(anotherLayout, currentAfterSwitch) + + // Restore original layout + val restored = ui { Application.chooseInputSource(originalLayout) } + assert(restored) { "Failed to restore to $originalLayout" } + + val currentAfterRestore = ui { Application.currentInputSource() } + assertEquals(originalLayout, currentAfterRestore) + } else { + println("Only one keyboard layout available, skipping switch test") + } + } + + @Test + fun `swedish test`() { + val layoutId = "com.apple.keylayout.Swedish-Pro" + assert(ui { Application.chooseInputSource(layoutId) }) + ui { robot.emulateKeyboardEvent(KeyCode.ANSI_Semicolon, true) } + ui { robot.emulateKeyboardEvent(KeyCode.ANSI_Semicolon, false) } + awaitEventOfType { it.typedCharacters == "ö" } + } +} diff --git a/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/TestUtils.kt b/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/TestUtils.kt index c0b663c3..ff7abf3d 100644 --- a/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/TestUtils.kt +++ b/kotlin-desktop-toolkit/src/test/kotlin/org/jetbrains/desktop/macos/tests/TestUtils.kt @@ -6,68 +6,86 @@ import org.jetbrains.desktop.macos.EventHandlerResult import org.jetbrains.desktop.macos.GrandCentralDispatch import org.jetbrains.desktop.macos.KotlinDesktopToolkit import org.jetbrains.desktop.macos.LogLevel +import org.jetbrains.desktop.macos.Logger import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Timeout -import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit import kotlin.concurrent.thread /** - * We expect that every test class will be executed with separate JVM instance, without parralle forks. + * We expect that every test class will be executed with a separate JVM instance, without parallel forks. * Tests from one test class also should be executed sequentially. - * This is requirement because tests interacts with NSApplication which can be initialized only once per process, + * This is a requirement because tests interact with NSApplication, which can be initialized only once per process, * moreover often the test might change OS state shared across different processes, so it's better to not run it - * in parrallel event in separate processes. + * in parallel event in separate processes. */ open class KDTTestBase { companion object { @BeforeAll @JvmStatic fun loadLibrary() { - KotlinDesktopToolkit.init(consoleLogLevel = LogLevel.Error) + KotlinDesktopToolkit.init( + consoleLogLevel = LogLevel.Info, + useDebugBuild = true, + ) } } } open class KDTApplicationTestBase : KDTTestBase() { + companion object { + fun withEventHandler(handler: (Event) -> EventHandlerResult, body: () -> T): T { + eventHandler = handler + val result = try { + body() + } finally { + eventHandler = null + } + return result + } - fun withEventHandler(handler: (Event) -> EventHandlerResult, body: () -> T): T { - eventHandler = handler - val result = body() - eventHandler = null - return result - } + fun ui(body: () -> T): T = GrandCentralDispatch.dispatchOnMainSync(highPriority = false, body) - fun ui(body: () -> T): T = GrandCentralDispatch.dispatchOnMainSync(highPriority = false, body) + val eventQueue = LinkedBlockingQueue() - companion object { + fun awaitEvent(predicate: (Event) -> Boolean): Event { + while (true) { + val event = eventQueue.take() + if (predicate(event)) return event + } + } + + inline fun awaitEventOfType(crossinline predicate: (T) -> Boolean): T { + return awaitEvent { it is T && predicate(it) } as T + } + + @Volatile var eventHandler: ((Event) -> EventHandlerResult)? = null @Volatile lateinit var handle: Thread - @Timeout(value = 5, unit = TimeUnit.SECONDS) + @Timeout(value = 20, unit = TimeUnit.SECONDS) @BeforeAll @JvmStatic fun startApplication() { - val applicationStartedLatch = CountDownLatch(1) handle = thread { GrandCentralDispatch.startOnMainThread { Application.init() Application.runEventLoop { event -> - if (event is Event.ApplicationDidFinishLaunching) { - applicationStartedLatch.countDown() - } + Logger.info { "Event: $event" } + assert(eventQueue.offer(event), { "Event queue overflow" }) eventHandler?.invoke(event) ?: EventHandlerResult.Continue } GrandCentralDispatch.close() } } - applicationStartedLatch.await() + awaitEvent { it is Event.ApplicationDidFinishLaunching } } - @Timeout(value = 5, unit = TimeUnit.SECONDS) + @Timeout(value = 20, unit = TimeUnit.SECONDS) @AfterAll @JvmStatic fun stopApplication() { diff --git a/native/Cargo.lock b/native/Cargo.lock index ba6a2794..0fd43c69 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -390,6 +390,7 @@ dependencies = [ "num-traits", "objc2", "objc2-app-kit", + "objc2-carbon", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation", @@ -1112,6 +1113,12 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-carbon" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1819e58fe713a21174bbbef4e8547a250a1bc21d17ff9938ceef9a7024da40ce" + [[package]] name = "objc2-cloud-kit" version = "0.3.2" diff --git a/native/desktop-macos/Cargo.toml b/native/desktop-macos/Cargo.toml index c1f12321..da06e5e8 100644 --- a/native/desktop-macos/Cargo.toml +++ b/native/desktop-macos/Cargo.toml @@ -34,7 +34,7 @@ block2 = { version = "0.6.2" } # it's nice to have at least for the beginning, but later we need to check how it impacts performance objc2 = { version = "0.6.3", features = ["exception", "catch-all"] } objc2-foundation = { version = "0.3.2" } -objc2-core-foundation = { version = "0.3.2" } +objc2-core-foundation = { version = "0.3.2", features = ["CFRunLoop", "CFDate", "CFMachPort"] } objc2-app-kit = { version = "0.3.2", features = [ "NSApplication", "NSWindow", @@ -61,7 +61,7 @@ objc2-app-kit = { version = "0.3.2", features = [ "objc2-quartz-core", ] } objc2-quartz-core = { version = "0.3.2" } -objc2-core-graphics = { version = "0.3.2" } +objc2-core-graphics = { version = "0.3.2", features = ["CGEvent", "CGEventTypes"] } objc2-metal = { version = "0.3.2" } objc2-uniform-type-identifiers = { version = "0.3.2" } objc2-user-notifications = { version = "0.3.2", features = [ @@ -75,5 +75,6 @@ objc2-user-notifications = { version = "0.3.2", features = [ "UNNotificationResponse", "UNNotification", ] } +objc2-carbon = { version = "0.3.2" } num-traits = "0.2" num-derive = "0.4" diff --git a/native/desktop-macos/src/macos/application_api.rs b/native/desktop-macos/src/macos/application_api.rs index 55412509..3413c9eb 100644 --- a/native/desktop-macos/src/macos/application_api.rs +++ b/native/desktop-macos/src/macos/application_api.rs @@ -13,6 +13,7 @@ use crate::macos::events::{ }; use crate::macos::image::Image; use anyhow::{Context, anyhow}; +use desktop_common::ffi_utils::AutoDropArray; use desktop_common::{ ffi_utils::{BorrowedStrPtr, RustAllocatedStrPtr}, logger::{catch_panic, ffi_boundary}, @@ -318,6 +319,129 @@ pub extern "C" fn application_open_url(url: BorrowedStrPtr) -> bool { }) } +#[allow(unused_doc_comments)] +/// cbindgen:ignore +#[link(name = "Carbon", kind = "framework")] +unsafe extern "C" { + pub(super) fn TISCopyCurrentKeyboardLayoutInputSource() -> *const c_void; + // Note: TISGetInputSourceProperty returns a borrowed reference, NOT an owned one + pub(super) fn TISGetInputSourceProperty(inputSource: *const c_void, propertyKey: *const c_void) -> *const c_void; + pub(super) fn TISCreateInputSourceList(properties: *const c_void, include_all_installed: bool) -> *const c_void; + pub(super) fn TISSelectInputSource(inputSource: *const c_void) -> i32; + #[allow(dead_code)] + pub(super) static kTISPropertyUnicodeKeyLayoutData: *const c_void; + pub(super) static kTISPropertyInputSourceID: *const c_void; + #[allow(dead_code)] + pub(super) static kTISPropertyLocalizedName: *const c_void; +} + +#[allow(unused_doc_comments)] +/// cbindgen:ignore +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFRelease(cf: *const c_void); + fn CFArrayGetCount(array: *const c_void) -> isize; + fn CFArrayGetValueAtIndex(array: *const c_void, index: isize) -> *const c_void; +} + +#[unsafe(no_mangle)] +pub extern "C" fn application_current_input_source() -> RustAllocatedStrPtr { + ffi_boundary("application_current_input_source", || { + let _mtm = MainThreadMarker::new().unwrap(); + unsafe { + let input_source = TISCopyCurrentKeyboardLayoutInputSource(); + if input_source.is_null() { + log::warn!("Can't get current keyboard layout"); + return Ok(RustAllocatedStrPtr::null()); + } + + let source_id_ptr = TISGetInputSourceProperty(input_source, kTISPropertyInputSourceID); + + let result = if source_id_ptr.is_null() { + Ok(RustAllocatedStrPtr::null()) + } else { + // source_id is a CFStringRef (borrowed), toll-free bridged to NSString + let ns_string: &NSString = &*source_id_ptr.cast::(); + copy_to_c_string(ns_string) + }; + + // Release the input source we got from TISCopyCurrentKeyboardLayoutInputSource + CFRelease(input_source); + + result + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn application_list_input_sources() -> AutoDropArray { + ffi_boundary("application_list_input_sources", || { + unsafe { + let source_list = TISCreateInputSourceList(std::ptr::null(), false); + if source_list.is_null() { + return Ok(AutoDropArray::new(Box::new([]))); + } + + #[allow(clippy::cast_sign_loss)] + let count = CFArrayGetCount(source_list) as usize; + + if count == 0 { + CFRelease(source_list); + return Ok(AutoDropArray::new(Box::new([]))); + } + + let mut source_ids: Vec = Vec::with_capacity(count); + + for i in 0..count { + let input_source = CFArrayGetValueAtIndex(source_list, i as isize); + let source_id_ptr = TISGetInputSourceProperty(input_source, kTISPropertyInputSourceID); + if !source_id_ptr.is_null() { + // source_id is a CFStringRef (borrowed), toll-free bridged to NSString + let ns_string: &NSString = &*source_id_ptr.cast::(); + source_ids.push(copy_to_c_string(ns_string)?); + } + } + + CFRelease(source_list); + + Ok(AutoDropArray::new(source_ids.into_boxed_slice())) + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn application_choose_input_source(source_id: BorrowedStrPtr) -> bool { + ffi_boundary("application_choose_input_source", || { + let source_id_str = source_id.as_str()?; + unsafe { + let source_list = TISCreateInputSourceList(std::ptr::null(), true); + if source_list.is_null() { + return Ok(false); + } + + #[allow(clippy::cast_sign_loss)] + let count = CFArrayGetCount(source_list) as usize; + let mut result = false; + + for i in 0..count { + let input_source = CFArrayGetValueAtIndex(source_list, i as isize); + let prop_ptr = TISGetInputSourceProperty(input_source, kTISPropertyInputSourceID); + if !prop_ptr.is_null() { + let ns_string: &NSString = &*prop_ptr.cast::(); + if ns_string.to_string() == source_id_str { + let status = TISSelectInputSource(input_source); + result = status == 0; // noErr + break; + } + } + } + + CFRelease(source_list); + Ok(result) + } + }) +} + define_class!( #[unsafe(super(NSApplication))] #[name = "MyNSApplication"] diff --git a/native/desktop-macos/src/macos/keyboard.rs b/native/desktop-macos/src/macos/keyboard.rs index 4ef17972..66cc967f 100644 --- a/native/desktop-macos/src/macos/keyboard.rs +++ b/native/desktop-macos/src/macos/keyboard.rs @@ -25,11 +25,11 @@ pub(crate) struct KeyEventInfo { // Can be considered as a name of the key // Depends on keyboard layout but ignores modifiers // For keys that depend on keyboard layout it will be the symbol typed for default layer - // For functional keys it will try to produce some meaningful codepoint, but not the same as for `characters` - // For dead keys it will produce text from default layer + // For functional keys it will try to produce some meaningful codepoint, but different from for `characters` + // For dead keys it will produce text from the default layer pub(crate) key: Retained, - // The same as key but also takes pressed modifiers into account + // The same as `key` but also takes pressed modifiers into account pub(crate) key_with_modifiers: Retained, pub(crate) modifiers: KeyModifiersSet, @@ -47,12 +47,12 @@ pub(crate) fn unpack_key_event(ns_event: &NSEvent) -> anyhow::Result, + event_counter: i64, +} + +impl Robot { + /// cbindgen:ignore + const EVENT_MARKER: i64 = 0x4B44_545F_524F_424F; // "KDT_ROBO" in hex + + pub(crate) fn new() -> anyhow::Result { + let robot = Self { + event_tap_thread: EventTapThread::new()?, + event_source: CGEventSource::new(CGEventSourceStateID::HIDSystemState).context("Can't create even source")?, + event_counter: 0, + }; + Ok(robot) + } + + const fn next_event_id(&mut self) -> i64 { + self.event_counter += 1; + Self::EVENT_MARKER ^ self.event_counter + } + + pub(crate) fn emulate_keyboard_event(&mut self, keycode: KeyCode, key_down: bool) -> anyhow::Result<()> { + let keycode = keycode.0; + let event_id = self.next_event_id(); + log::debug!( + "Emulate key press: {:?} {} event_id: {}", + keycode, + if key_down { "down" } else { "up" }, + event_id + ); + CGEventSource::set_user_data(Some(&self.event_source), event_id); + let key_event = CGEvent::new_keyboard_event(Some(&self.event_source), keycode, key_down).context("Failed to create key event")?; + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_event)); + self.event_tap_thread.wait_for_event(event_id); + Ok(()) + } + + pub(crate) fn shutdown(&mut self) -> anyhow::Result<()> { + self.event_tap_thread.join() + } +} + +struct EventTapThread { + #[allow(dead_code)] + handle: Option>, + events_data_rcv: std::sync::mpsc::Receiver, + run_loop_wrapper: RunLoopWrapper, +} + +struct RunLoopWrapper(CFRetained); +impl RunLoopWrapper { + fn stop(&self) { + self.0.stop(); + } +} + +// SAFETY: Still under discussion, see: +// https://github.com/madsmtm/objc2/issues/696 +#[allow(clippy::non_send_fields_in_send_ty)] +unsafe impl Send for RunLoopWrapper {} + +struct TapSubscription { + tap: CFRetained, + run_loop_source: CFRetained, + run_loop: CFRetained, +} + +impl EventTapThread { + /// cbindgen:ignore + #[unsafe(no_mangle)] + unsafe extern "C-unwind" fn event_tap_callback( + _proxy: CGEventTapProxy, + event_type: CGEventType, + event: NonNull, + user_info: *mut c_void, + ) -> *mut CGEvent { + let event_ref = unsafe { event.as_ref() }; + let user_data = CGEvent::integer_value_field(Some(event_ref), CGEventField::EventSourceUserData); + log::debug!("Got event of type: {event_type:?} with id: {user_data}"); + let events_data_snd_ptr = user_info.cast::>(); + let event_data_snd = unsafe { events_data_snd_ptr.as_ref() }.unwrap_or_else(|| panic!("user_info: {user_info:?}")); + event_data_snd + .send(user_data) + .unwrap_or_else(|_| log::error!("Failed to send event data")); + event.as_ptr() + } + + const fn create_keyboard_event_mask() -> CGEventMask { + (1 << CGEventType::KeyDown.0) | (1 << CGEventType::KeyUp.0) | (1 << CGEventType::FlagsChanged.0) + } + + fn new() -> anyhow::Result { + let (mark_is_ready, check_is_ready) = std::sync::mpsc::sync_channel::>(1); + let (events_data_snd, events_data_rcv) = std::sync::mpsc::sync_channel::(1); + let handle = thread::spawn(move || { + // Safety: the Arc is alive until the event loop is running + let events_data_snd = Arc::new(events_data_snd); + match Self::create_tap_subscription(Arc::as_ptr(&events_data_snd)) { + Ok(subscription) => { + mark_is_ready + .send(Ok(RunLoopWrapper(subscription.run_loop.clone()))) + .expect("Can't fail here"); + CFRunLoop::run(); + Self::remove_tap_subscription(subscription); + } + Err(err) => { + mark_is_ready.send(Err(err)).expect("Can't fail here"); + } + } + }); + + let run_loop_wrapper = check_is_ready.recv().expect("Can't fail here")?; + + Ok(Self { + handle: Some(handle), + events_data_rcv, + run_loop_wrapper, + }) + } + + fn create_tap_subscription(events_data_snd_ptr: *const SyncSender) -> anyhow::Result { + let callback: CGEventTapCallBack = Some(Self::event_tap_callback); + let tap = unsafe { + CGEvent::tap_create( + CGEventTapLocation::HIDEventTap, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + Self::create_keyboard_event_mask(), + callback, + events_data_snd_ptr.cast_mut().cast::(), + ) + } + .context("Failed to create event tap. Check accessibility permissions.")?; + + let run_loop_source = CFMachPort::new_run_loop_source(None, Some(&tap), 0).context("Failed to create run loop source")?; + let run_loop = CFRunLoop::current().context("Failed to get current run loop")?; + run_loop.add_source(Some(&run_loop_source), unsafe { kCFRunLoopDefaultMode }); + + Ok(TapSubscription { + tap, + run_loop_source, + run_loop, + }) + } + + fn remove_tap_subscription(subscription: TapSubscription) { + subscription + .run_loop + .remove_source(Some(&subscription.run_loop_source), unsafe { kCFRunLoopDefaultMode }); + subscription.tap.invalidate(); + drop(subscription); + } + + fn wait_for_event(&self, event_id: i64) { + self.events_data_rcv.iter().find(|it| *it == event_id); + } + + fn join(&mut self) -> anyhow::Result<()> { + self.run_loop_wrapper.stop(); + self.handle.take().context("Already joined")?.join().expect("Failed to join"); + Ok(()) + } +} diff --git a/native/desktop-macos/src/macos/robot_api.rs b/native/desktop-macos/src/macos/robot_api.rs new file mode 100644 index 00000000..55af118f --- /dev/null +++ b/native/desktop-macos/src/macos/robot_api.rs @@ -0,0 +1,54 @@ +use crate::macos::keyboard::KeyCode; +use crate::macos::robot::Robot; +use anyhow::{Context, bail}; +use desktop_common::logger::ffi_boundary; +use objc2::MainThreadMarker; +use std::cell::RefCell; + +thread_local! { + static ROBOT: RefCell> = const { RefCell::new(None) }; +} + +#[unsafe(no_mangle)] +pub extern "C" fn robot_initialize() { + ffi_boundary("robot_initialize", || { + let _mtm = MainThreadMarker::new().context("Robot can be initialized only from the main thread")?; + ROBOT.with_borrow_mut(|robot| { + if robot.is_some() { + bail!("Robot is already initialized"); + } + robot.replace(Robot::new()?); + Ok(()) + }) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn robot_deinitialize() { + ffi_boundary("robot_deinitialize", || { + let _mtm = MainThreadMarker::new().context("Robot can be initialized only from the main thread")?; + ROBOT.with_borrow_mut(|robot| { + match robot.take() { + None => { + bail!("Robot is not initialized"); + } + Some(mut robot) => { + robot.shutdown()?; + } + } + Ok(()) + }) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn emulate_keyboard_event(keycode: KeyCode, key_down: bool) { + ffi_boundary("emulate_key_press", || { + let _mtm = MainThreadMarker::new().context("Robot can be initialized only from the main thread")?; + ROBOT.with_borrow_mut(|robot| { + let robot = robot.as_mut().context("Robot is not initialized")?; + robot.emulate_keyboard_event(keycode, key_down)?; + Ok(()) + }) + }); +} diff --git a/sample/src/main/kotlin/org/jetbrains/desktop/sample/macos/SkikoSampleMac.kt b/sample/src/main/kotlin/org/jetbrains/desktop/sample/macos/SkikoSampleMac.kt index a0f5dc7d..27b89341 100644 --- a/sample/src/main/kotlin/org/jetbrains/desktop/sample/macos/SkikoSampleMac.kt +++ b/sample/src/main/kotlin/org/jetbrains/desktop/sample/macos/SkikoSampleMac.kt @@ -647,6 +647,14 @@ class ApplicationState : AutoCloseable { ), AppMenuItem.SubMenu( title = "Edit", + AppMenuItem.Action( + title = "Log input source", + keystroke = Keystroke(key = "x", modifiers = KeyModifiersSet.create(command = true)), + specialTag = AppMenuItem.Action.SpecialTag.Cut, + perform = { + Logger.info { "Current input source: ${Application.currentInputSource()}" } + }, + ), AppMenuItem.Action( title = "Cut", keystroke = Keystroke(key = "x", modifiers = KeyModifiersSet.create(command = true)), diff --git a/scripts/macos_install_input_sources.sh b/scripts/macos_install_input_sources.sh new file mode 100755 index 00000000..eeff2f64 --- /dev/null +++ b/scripts/macos_install_input_sources.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +# Colours +green='\033[0;32m' +yellow='\033[0;33m' +reset='\033[0m' + +# Check for dry-run mode +DRY_RUN=false +if [[ "$1" == "--dry-run" ]]; then + DRY_RUN=true + echo -e "${yellow}Running in dry-run mode - no changes will be made${reset}" +fi + +# Kill Preferences, just incase it's running, supress potential warning +# about no running process +killall 'System Preferences' &>/dev/null + +# Format: "Layout Name:ID" +keyboard_layouts=( + 'USInternational-PC:1500' + 'Swedish - Pro:7' + 'ABC:252' + 'Russian:19456' + 'German:3' + 'Serbian-Latin:-19521' + 'Serbian:19521' + 'Dvorak:16300' + 'DVORAK - QWERTY CMD:16301' +) + +# The keys we have to add for each layout +apple_keys=("AppleEnabledInputSources") + +# Function to check if input source already exists +input_source_exists() { + local key="$1" + local layout_id="$2" + local layout_name="$3" + + # Get current array contents + local current=$(defaults -host "${USER}" read com.apple.HIToolbox "$key" 2>/dev/null) + + # Check if the specific layout already exists (format: "KeyboardLayout ID" = 1500;) + if [[ "$current" == *"\"KeyboardLayout ID\" = ${layout_id};"* ]]; then + return 0 # exists + else + return 1 # doesn't exist + fi +} + +for entry in "${keyboard_layouts[@]}"; do + layout_name="${entry%:*}" + layout_id="${entry##*:}" + + for key in "${apple_keys[@]}"; do + # Only add if it doesn't already exist + if ! input_source_exists "$key" "$layout_id" "$layout_name"; then + echo "Adding $layout_name to $key" + if [[ "$DRY_RUN" == "false" ]]; then + defaults -host "${USER}" write com.apple.HIToolbox \ + "$key" \ + -array-add "InputSourceKindKeyboard Layout"\ +"KeyboardLayout ID${layout_id}"\ +"KeyboardLayout Name${layout_name}" + else + echo " [DRY-RUN] Would add entry" + fi + else + echo "$layout_name already exists in $key, skipping" + fi + done +done + +# ============================================================================= +# Input Methods (e.g., Japanese, Chinese) +# ============================================================================= + +# Format: "Bundle ID:Input Mode" +input_methods=( + 'com.apple.inputmethod.Kotoeri.RomajiTyping:com.apple.inputmethod.Japanese' + 'com.apple.inputmethod.Kotoeri.KanaTyping:com.apple.inputmethod.Japanese' + 'com.apple.inputmethod.TCIM:com.apple.inputmethod.TCIM.Pinyin' + 'com.apple.inputmethod.Korean:com.apple.inputmethod.Korean.2SetKorean' +) + +# Function to check if input method already exists +input_method_exists() { + local key="$1" + local bundle_id="$2" + + local current=$(defaults -host "${USER}" read com.apple.HIToolbox "$key" 2>/dev/null) + + if [[ "$current" == *"\"Bundle ID\" = \"${bundle_id}\";"* ]]; then + return 0 # exists + else + return 1 # doesn't exist + fi +} + +for entry in "${input_methods[@]}"; do + bundle_id="${entry%:*}" + input_mode="${entry##*:}" + + for key in "${apple_keys[@]}"; do + if ! input_method_exists "$key" "$bundle_id"; then + echo "Adding input method $bundle_id to $key" + if [[ "$DRY_RUN" == "false" ]]; then + # Add the "Keyboard Input Method" entry + defaults -host "${USER}" write com.apple.HIToolbox \ + "$key" \ + -array-add "Bundle ID${bundle_id}"\ +"InputSourceKindKeyboard Input Method" + # Add the "Input Mode" entry + defaults -host "${USER}" write com.apple.HIToolbox \ + "$key" \ + -array-add "Bundle ID${bundle_id}"\ +"Input Mode${input_mode}"\ +"InputSourceKindInput Mode" + else + echo " [DRY-RUN] Would add entry" + fi + else + echo "Input method $bundle_id already exists in $key, skipping" + fi + done +done + +killall TextInputMenuAgent +killall cfprefsd +sleep 3 + +echo -e "${green}Successfully set default values for input sources! ${reset}"