diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8f4930e1e..0e6a3846f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -112,6 +112,11 @@ jobs: - name: Run clippy simulated output run: cargo clippy --all --features=simulated_output,cmd -- -D warnings + - name: Run tests gui + run: cargo test --all --features=gui + - name: Run clippy gui + run: cargo clippy --all --features=gui -- -D warnings + build-test-clippy-macos: runs-on: ${{ matrix.os }} strategy: diff --git a/Cargo.lock b/Cargo.lock index ff37ba24a..250953b9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,7 +214,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.55", ] [[package]] @@ -280,7 +280,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml", + "toml 0.8.12", "vswhom", "winreg", ] @@ -504,6 +504,7 @@ dependencies = [ "log", "miette", "mio", + "native-windows-derive", "native-windows-gui", "nix 0.26.4", "once_cell", @@ -516,7 +517,9 @@ dependencies = [ "signal-hook", "simplelog", "time", + "win_dbg_logger", "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -704,7 +707,7 @@ checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.55", ] [[package]] @@ -728,6 +731,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "muldiv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0419348c027fa7be448d2ae7ea0e4e04c2334c31dc4e74ab29f00a2a7ca69204" + +[[package]] +name = "native-windows-derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76134ae81020d89d154f619fd2495a2cecad204276b1dc21174b55e4d0975edd" +dependencies = [ + "proc-macro-crate 0.1.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "native-windows-gui" version = "1.0.13" @@ -736,6 +757,7 @@ checksum = "4f7003a669f68deb6b7c57d74fff4f8e533c44a3f0b297492440ef4ff5a28454" dependencies = [ "bitflags 1.3.2", "lazy_static", + "muldiv", "winapi", "winapi-build", ] @@ -796,10 +818,10 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", - "syn", + "syn 2.0.55", ] [[package]] @@ -893,6 +915,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -1048,7 +1079,7 @@ checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.55", ] [[package]] @@ -1162,6 +1193,17 @@ dependencies = [ "is-terminal", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.55" @@ -1226,7 +1268,7 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.55", ] [[package]] @@ -1262,6 +1304,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.12" @@ -1378,7 +1429,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.55", "wasm-bindgen-shared", ] @@ -1400,7 +1451,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.55", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1421,6 +1472,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "win_dbg_logger" +version = "0.1.0" +dependencies = [ + "log", + "regex", + "simplelog", + "winapi", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index f48744d74..ebd36be7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "tcp_protocol", "simulated_input", "windows_key_tester", + "win_dbg_logger", ] exclude = [ "wasm", @@ -79,7 +80,23 @@ winapi = { version = "0.3.9", features = [ "timeapi", "mmsystem", ] } -native-windows-gui = { version = "1.0.12", default_features = false } +windows-sys = { version = "0.52.0", features = [ + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Usb", + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Security", + "Win32_System_Diagnostics_Debug", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_UI_Controls", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +], optional=true } +win_dbg_logger = { path = "win_dbg_logger", optional = true } +native-windows-gui = { version = "1.0.13", default_features = false} +native-windows-derive = { version = "1.0.5", default_features = false, optional = true } +regex = { version = "1.10.4", optional = true } kanata-interception = { version = "0.2.0", optional = true } [target.'cfg(target_os = "windows")'.build-dependencies] @@ -98,6 +115,7 @@ cmd = ["kanata-parser/cmd"] interception_driver = ["kanata-interception", "kanata-parser/interception_driver"] simulated_output = ["indoc"] wasm = [ "instant/wasm-bindgen" ] +gui = ["win_manifest","native-windows-derive","win_dbg_logger","win_dbg_logger/simple_shared","kanata-parser/gui","native-windows-gui/tray-notification","native-windows-gui/message-window","native-windows-gui/menu","native-windows-gui/cursor","native-windows-gui/high-dpi","native-windows-gui/embed-resource","native-windows-gui/image-decoder","native-windows-gui/notice","dep:windows-sys"] [profile.release] opt-level = "z" diff --git a/EnableUIAccess/EnableUIAccess.ahk b/EnableUIAccess/EnableUIAccess.ahk deleted file mode 100644 index 5d75f0d4a..000000000 --- a/EnableUIAccess/EnableUIAccess.ahk +++ /dev/null @@ -1,346 +0,0 @@ -/* - EnableUIAccess.ahk v1.01 by Lexikos - - USE AT YOUR OWN RISK - - Enables the uiAccess flag in an application's embedded manifest - and signs the file with a self-signed digital certificate. If the - file is in a trusted location (A_ProgramFiles or A_WinDir), this - allows the application to bypass UIPI (User Interface Privilege - Isolation, a part of User Account Control in Vista/7). It also - enables the journal playback hook (SendPlay). - - Command line params (mutually exclusive): - SkipWarning - don't display the initial warning - "" "" - attempt to run silently using the given file(s) - - This script and the provided Lib files may be used, modified, - copied, etc. without restriction. - -*/ - -#NoEnv - -#Include -#Include -#Include -#Include - -; Command line args: -in_file = %1% -out_file = %2% - -if (in_file = "") -MsgBox 49,, -(Join -This script enables the selected AutoHotkey.exe to bypass restrictions - imposed by UIPI, a component of UAC in Windows Vista and 7. To do this - it modifies an attribute in the file's embedded manifest and signs the - file using a self-signed digital certificate, which is then installed - in the local machine's Trusted Root Certification Authorities store.`n -`n -THE RESULTING EXECUTABLE MAY BE UNUSABLE ON ANY SYSTEM WHERE THIS - CERTIFICATE IS NOT INSTALLED.`n -`n -Continue at your own risk. -) -ifMsgBox Cancel - ExitApp - -if !A_IsAdmin -{ - if (in_file = "") - in_file := "SkipWarning" - cmd = "%A_ScriptFullPath%" - if !A_IsCompiled - { ; Use A_AhkPath in case the "runas" verb isn't registered for ahk files. - cmd = "%A_AhkPath%" %cmd% - } - Run *RunAs %cmd% "%in_file%" "%out_file%",, UseErrorLevel - ExitApp -} - -if (in_file = "" || in_file = "SkipWarning") -{ - ; Find AutoHotkey installation. - RegRead InstallDir, HKEY_LOCAL_MACHINE, SOFTWARE\AutoHotkey, InstallDir - if ErrorLevel && A_PtrSize=8 - RegRead InstallDir, HKLM, SOFTWARE\Wow6432Node\AutoHotkey, InstallDir - - ; Let user confirm or select file(s). - FileSelectFile in_file, 1, %InstallDir%\AutoHotkey.exe - , Select Source File, Executable Files (*.exe) - if ErrorLevel - ExitApp - FileSelectFile out_file, S16, %in_file% - , Select Destination File, Executable Files (*.exe) - if ErrorLevel - ExitApp - user_specified_files := true -} - -; Convert short paths to long paths. -Loop %in_file%, 0 - in_file := A_LoopFileLongPath -if (out_file = "") ; i.e. only one file was given via command line. - out_file := in_file -else - Loop %out_file%, 0 - out_file := A_LoopFileLongPath - -if Crypt.IsSigned(in_file) -{ - MsgBox 48,, Input file is already signed. The script will now exit. - ExitApp -} - -if user_specified_files && !IsTrustedLocation(out_file) -{ - MsgBox 49,, - (LTrim Join`s - The path you have selected is not a trusted location. If you choose - to continue, the uiAccess attribute will be set but will not have - any effect until the file is moved to a trusted location. Trusted - locations include \Program Files\ and \Windows\System32\. - ) - ifMsgBox Cancel - ExitApp -} - -if (in_file = out_file) -{ - ; The following should typically work even if the file is in use: - bak_file := in_file "~" A_Now ".bak" - FileMove %in_file%, %bak_file%, 1 - if ErrorLevel - Fail("Failed to rename selected file.") - in_file := bak_file -} -FileCopy %in_file%, %out_file%, 1 -if ErrorLevel - Fail("Failed to copy file to destination.") - - -; Set the uiAccess attribute in the file's manifest to "true". -if !EXE_uiAccess_set(out_file, true) - Fail("Failed to set uiAccess attribute in manifest.") - - -; Open the current user's "Personal" certificate store. -my := Cert.OpenStore(Cert.STORE_PROV_SYSTEM, 0, Cert.SYSTEM_STORE_CURRENT_USER, "wstr", "My") -if !my - Warn("Failed to open 'Personal' certificate store.") - -; Locate "AutoHotkey" certificate created by a previous run of this script. -ahk_cert := my.FindCertificates(0, Cert.FIND_SUBJECT_STR, "wstr", "AutoHotkey")[1] - -if !ahk_cert -{ - ; Create key container. - cr := Crypt.AcquireContext("AutoHotkey", 0, Crypt.PROV_RSA_FULL, Crypt.NEWKEYSET) - if !cr - Fail("Failed to create 'AutoHotkey' key container.") - - ; Generate key for certificate. - key := cr.GenerateKey(Crypt.AT_SIGNATURE, 1024, Crypt.EXPORTABLE) - - ; Create simple certificate name. - cn := new Cert.Name({CommonName: "AutoHotkey"}) - - ; Set end time to 10 years from now. - end_time := SystemTime.Now() - end_time.Year += 10 - - ; Create certificate using the parameters created above. - ahk_cert := cr.CreateSelfSignCertificate(cn, 0, end_time) - if !ahk_cert - Fail("Failed to create 'AutoHotkey' certificate.") - - ; Add certificate to current user's "Personal" store so they won't - ; need to create it again if they need to update the executable. - if !(my.AddCertificate(ahk_cert, Cert.STORE_ADD_NEW)) - Warn("Failed to add certificate to 'Personal' store.") - ; Proceed even if above failed, since it probably doesn't matter. - - ; Attempt to install certificate in trusted store. - root := Cert.OpenStore(Cert.STORE_PROV_SYSTEM, 0, Cert.SYSTEM_STORE_LOCAL_MACHINE, "wstr", "Root") - if !(root && root.AddCertificate(ahk_cert, Cert.STORE_ADD_USE_EXISTING)) - { - if (%True% != "Silent") - MsgBox 49,, - (LTrim Join`s - Failed to install certificate. If you continue, the executable - may become unusable until the certificate is manually installed. - This can typically be done via Digital Signatures tab on the - file's Properties dialog. - ) - ifMsgBox Cancel - ExitApp - } - - key.Dispose() - cr.Dispose() -} - -; Sign the file. -if !SignFile(out_file, ahk_cert, "AutoHotkey") - Fail("Failed to sign file.") - - -; In interactive mode, if not overwriting the original file, offer -; to create an additional context menu item for AHK files. -if (user_specified_files && in_file != out_file) -{ - RegRead uiAccessVerb, HKCR, AutoHotkeyScript\Shell\uiAccess\Command - if ErrorLevel - { - MsgBox 3,, Register "Run Script with UI Access" context menu item? - ifMsgBox Yes - { - RegWrite REG_SZ, HKCR, AutoHotkeyScript\Shell\uiAccess - ,, Run with UI Access - RegWrite REG_SZ, HKCR, AutoHotkeyScript\Shell\uiAccess\Command - ,, "%out_file%" "`%1" `%* - } - ifMsgBox Cancel - ExitApp - } -} - - -; IsTrustedLocation -; Returns true if path is a valid location for uiAccess="true". -IsTrustedLocation(path) -{ - ; http://msdn.microsoft.com/en-us/library/bb756929 - ; MSDN: "\Program Files\ and \windows\system32\ are currently the - ; two allowable protected locations." - ; However, \Program Files (x86)\ also appears to be allowed. - if InStr(path, A_ProgramFiles "\") = 1 - return true - if InStr(path, A_WinDir "\System32\") = 1 - return true - - ; On 64-bit systems, if this script is 32-bit, A_ProgramFiles is - ; %ProgramFiles(x86)%, otherwise it is %ProgramW6432%. So check - ; the opposite "Program Files" folder: - EnvGet other, % A_PtrSize=8 ? "ProgramFiles(x86)" : "ProgramW6432" - if (other != "" && InStr(path, other "\") = 1) - return true - - return false -} - - -; EXE_uiAccess_set -; Sets the uiAccess attribute in an executable file's manifest. -; file - Path of file. -; value - New value; must be boolean (0 or 1). -EXE_uiAccess_set(file, value) -{ - ; Load manifest from EXE file. - xml := ComObjCreate("Msxml2.DOMDocument") - xml.async := false - xml.setProperty("SelectionLanguage", "XPath") - xml.setProperty("SelectionNamespaces" - , "xmlns:v1='urn:schemas-microsoft-com:asm.v1' " - . "xmlns:v3='urn:schemas-microsoft-com:asm.v3'") - if !xml.load("res://" file "/#24/#1") - { - ; This will happen if the file doesn't exist or can't be opened, - ; or if it doesn't have an embedded manifest. - ErrorLevel := "load" - return false - } - - ; Check if any change is necessary. If the uiAccess attribute is - ; not present, it is effectively "false": - node := xml.selectSingleNode("/v1:assembly/v3:trustInfo/security" - . "/requestedPrivileges/requestedExecutionLevel") - if ((node && node.getAttribute("uiAccess") = "true") = value) - { - ErrorLevel := "already set" - return true - } - - ; The follow "IF" section should be unnecessary for AutoHotkey_L. - if !node - { - ; Get assembly node, which should always exist. - if !last := xml.selectSingleNode("/v1:assembly") - { - ErrorLevel := "invalid manifest" - return 0 - } - for _, name in ["trustInfo", "security", "requestedPrivileges" - , "requestedExecutionLevel"] - { - if !(node := last.selectSingleNode("*[local-name()='" name "']")) - { - static NODE_ELEMENT := 1 - node := xml.createNode(NODE_ELEMENT, name - , "urn:schemas-microsoft-com:asm.v3") - last.appendChild(node) - } - last := node - } - ; Since the requestedExecutionLevel node didn't exist before, - ; we must have just created it. Although this attribute *might* - ; not actually be required, it seems best to set it: - node.setAttribute("level", "asInvoker") - } - - ; Set the uiAccess attribute! - node.setAttribute("uiAccess", value ? "true" : "false") - - ; Retrieve XML text. - xml := RTrim(xml.xml, "`r`n") - - ; Convert to UTF-8. - VarSetCapacity(data, data_size := StrPut(xml, "utf-8") - 1) - StrPut(xml, &data, "utf-8") - - ; - ; Replace manifest resource. - ; - - hupd := DllCall("BeginUpdateResource", "str", file, "int", false) - if !hupd - { - ErrorLevel := "BeginUpdateResource" - return false - } - - ; Res type RT_MANIFEST (24), resource ID 1, language English (US) - r := DllCall("UpdateResource", "ptr", hupd, "ptr", 24, "ptr", 1 - , "ushort", 1033, "ptr", &data, "uint", data_size) - - if !DllCall("EndUpdateResource", "ptr", hupd, "int", !r) - { - ErrorLevel := "EndUpdateResource" - return false - } - if !r ; i.e. above succeeded only in discarding the failed changes. - { - ErrorLevel := "UpdateResource" - return false - } - ; Success! - ErrorLevel := 0 - return true -} - - -Fail(msg) -{ - if (%True% != "Silent") - MsgBox 16,, %msg%`n`nErrorLevel: %ErrorLevel%`nA_LastError: %A_LastError% - ExitApp -} - -Warn(msg) -{ - msg .= " (Err " ErrorLevel "; " A_LastError ")`n" - OutputDebug %msg% - FileAppend %msg%, * -} \ No newline at end of file diff --git a/EnableUIAccess/EnableUIAccess_launch.ahk b/EnableUIAccess/EnableUIAccess_launch.ahk new file mode 100644 index 000000000..891193edd --- /dev/null +++ b/EnableUIAccess/EnableUIAccess_launch.ahk @@ -0,0 +1,134 @@ +#requires AutoHotkey v2.0 +#SingleInstance Off ; Needed for elevation with *runas. +/* v2 based on EnableUIAccess.ahk v1.01 by Lexikos USE AT YOUR OWN RISK + Enables the uiAccess flag in an application's embedded manifest and signs the file with a self-signed digital certificate. If the file is in a trusted location (A_ProgramFiles or A_WinDir), this allows the application to bypass UIPI (User Interface Privilege Isolation, a part of User Account Control in Vista/7). It also enables the journal playback hook (SendPlay). + Command line params (mutually exclusive): + SkipWarning - don't display the initial warning + "" "" - attempt to run silently using the given file(s) + This script and the provided Lib files may be used, modified, copied, etc. without restriction. +*/ +#include + +in_file := (A_Args.Has(1))?A_Args[1]:'' ; Command line args +out_file := (A_Args.Has(2))?A_Args[2]:'' + +if (in_file = ""){ + msgResult := MsgBox("Enable the selected EXE to bypass UAC-UIPI security restrictions imposed by modifying 'UIAccess' attribute in the file's embedded manifest and signing the file using a self-signed digital certificate, which is then installed in the local machine's Trusted Root Certification Authorities store.`n`nThe resulting EXE is unusable on a system without this certificate installed!`n`nContinue at your own risk", "", 49) + if (msgResult = "Cancel"){ + ExitApp() + } +} + +if !A_IsAdmin { + if (in_file = "") { + in_file := "SkipWarning" + } + cmd := "`"" . A_ScriptFullPath . "`"" + if !A_IsCompiled { ; Use A_AhkPath in case the "runas" verb isn't registered for ahk files. + cmd := "`"" . A_AhkPath . "`" " . cmd + } + Try Run("*RunAs " cmd " `"" in_file "`" `"" out_file "`"", , "", ) + ExitApp() +} +global user_specified_files := false +if (in_file = "" || in_file = "SkipWarning") { ; Find AutoHotkey installation. + InstallDir := RegRead("HKEY_LOCAL_MACHINE\SOFTWARE\AutoHotkey", "InstallDir") + if A_LastError && A_PtrSize=8 { + InstallDir := RegRead("HKLM\SOFTWARE\Wow6432Node\AutoHotkey", "InstallDir") + } + ; Let user confirm or select file(s). + in_file := FileSelect(1, InstallDir "\AutoHotkey.exe", "Select Source File", "Executable Files (*.exe)") + if A_LastError { + ExitApp() + } + out_file := FileSelect("S16", in_file, "Select Destination File", "Executable Files (*.exe)") + if A_LastError { + ExitApp() + } + user_specified_files := true +} + +Loop in_file { ; Convert short paths to long paths + in_file := A_LoopFileFullPath +} +if (out_file = "") { ; i.e. only one file was given via command line + out_file := in_file +} else { + Loop out_file { + out_file := A_LoopFileFullPath + } +} +if Crypt.IsSigned(in_file) { + msgResult := MsgBox("Input file is already signed. The script will now exit" in_file,"", 48) + ExitApp() +} + +if user_specified_files && !IsTrustedLocation(out_file) { + msgResult := MsgBox("Target path is not a trusted location (Program Files or Windows\System32), so 'uiAccess' will have no effect until the file is moved there","", 49) + if (msgResult = "Cancel") { + ExitApp() + } +} + +if (in_file = out_file) { ; The following should typically work even if the file is in use + bak_file := in_file "~" A_Now ".bak" + FileMove(in_file, bak_file, 1) + if A_LastError { + Fail("Failed to rename selected file.") + } + in_file := bak_file +} +Try { + FileCopy(in_file, out_file, 1) +} Catch as Err { + throw OSError(Err) +} +if A_LastError { + Fail("Failed to copy file to destination.") +} + +if !EnableUIAccess(out_file) { ; Set the uiAccess attribute in the file's manifest + Fail("Failed to set uiAccess attribute in manifest") +} + + +if (user_specified_files && in_file != out_file) { ; in interactive mode, if not overwriting the original file, offer to create an additional context menu item for AHK files + uiAccessVerb := RegRead("HKCR\AutoHotkeyScript\Shell\uiAccess\Command") + if A_LastError { + msgResult := MsgBox("Register `"Run Script with UI Access`" context menu item?", "", 3) + if (msgResult = "Yes") { + RegWrite("Run with UI Access", "REG_SZ", "HKCR\AutoHotkeyScript\Shell\uiAccess") + RegWrite("`"" out_file "`" `"`%1`" `%*", "REG_SZ", "HKCR\AutoHotkeyScript\Shell\uiAccess\Command") + } + if (msgResult = "Cancel") + ExitApp() + } +} + +IsTrustedLocation(path) { ; IsTrustedLocation →true if path is a valid location for uiAccess="true" + ; http://msdn.microsoft.com/en-us/library/bb756929 "\Program Files\ and \windows\system32\ are currently 2 allowable protected locations." However, \Program Files (x86)\ also appears to be allowed + if InStr(path, A_ProgramFiles "\") = 1 { + return true + } + if InStr(path, A_WinDir "\System32\") = 1 { + return true + } + other := EnvGet(A_PtrSize=8 ? "ProgramFiles(x86)" : "ProgramW6432") ; On 64-bit systems, if this script is 32-bit, A_ProgramFiles is %ProgramFiles(x86)%, otherwise it is %ProgramW6432%. So check the opposite "Program Files" folder: + if (other != "" && InStr(path, other "\") = 1) { + return true + } + return false +} + +Fail(msg) { + ; if (%True% != "Silent") { ;??? + MsgBox(msg "`nA_LastError: " A_LastError, "", 16) + ; } + ExitApp() +} + +Warn(msg) { + msg .= " (Err " A_LastError ")`n" + OutputDebug(msg) + FileAppend(msg, "*") +} diff --git a/EnableUIAccess/Lib/Cert.ahk b/EnableUIAccess/Lib/Cert.ahk deleted file mode 100644 index 7acba6d6a..000000000 --- a/EnableUIAccess/Lib/Cert.ahk +++ /dev/null @@ -1,384 +0,0 @@ -class Cert -{ - ; Encoding Types -static X509_ASN_ENCODING := 0x00000001 - , PKCS_7_ASN_ENCODING := 0x00010000 - - ; Certificate Information Flags (CERT_INFO_*) -static INFO_VERSION_FLAG := 1 - , INFO_SERIAL_NUMBER_FLAG := 2 - , INFO_SIGNATURE_ALGORITHM_FLAG := 3 - , INFO_ISSUER_FLAG := 4 - , INFO_NOT_BEFORE_FLAG := 5 - , INFO_NOT_AFTER_FLAG := 6 - , INFO_SUBJECT_FLAG := 7 - , INFO_SUBJECT_PUBLIC_KEY_INFO_FLAG := 8 - , INFO_ISSUER_UNIQUE_ID_FLAG := 9 - , INFO_SUBJECT_UNIQUE_ID_FLAG := 10 - , INFO_EXTENSION_FLAG := 11 - - ; Certificate Comparison Functions (CERT_COMPARE_*) -static COMPARE_MASK := 0xFFFF - , COMPARE_SHIFT := (_ := 16) - , COMPARE_ANY := 0 - , COMPARE_SHA1_HASH := 1 - , COMPARE_NAME := 2 - , COMPARE_ATTR := 3 - , COMPARE_MD5_HASH := 4 - , COMPARE_PROPERTY := 5 - , COMPARE_PUBLIC_KEY := 6 - , COMPARE_HASH := Cert.COMPARE_SHA1_HASH - , COMPARE_NAME_STR_A := 7 - , COMPARE_NAME_STR_W := 8 - , COMPARE_KEY_SPEC := 9 - , COMPARE_ENHKEY_USAGE := 10 - , COMPARE_CTL_USAGE := Cert.COMPARE_ENHKEY_USAGE - , COMPARE_SUBJECT_CERT := 11 - , COMPARE_ISSUER_OF := 12 - , COMPARE_EXISTING := 13 - , COMPARE_SIGNATURE_HASH := 14 - , COMPARE_KEY_IDENTIFIER := 15 - , COMPARE_CERT_ID := 16 - , COMPARE_CROSS_CERT_DIST_POINTS := 17 - , COMPARE_PUBKEY_MD5_HASH := 18 - , COMPARE_SUBJECT_INFO_ACCESS := 19 - - ; dwFindType Flags (CERT_FIND_*) -static FIND_ANY := Cert.COMPARE_ANY << _ - , FIND_SHA1_HASH := Cert.COMPARE_SHA1_HASH << _ - , FIND_MD5_HASH := Cert.COMPARE_MD5_HASH << _ - , FIND_SIGNATURE_HASH := Cert.COMPARE_SIGNATURE_HASH << _ - , FIND_KEY_IDENTIFIER := Cert.COMPARE_KEY_IDENTIFIER << _ - , FIND_HASH := Cert.FIND_SHA1_HASH - , FIND_PROPERTY := Cert.COMPARE_PROPERTY << _ - , FIND_PUBLIC_KEY := Cert.COMPARE_PUBLIC_KEY << _ - , FIND_SUBJECT_NAME := (Cert.COMPARE_NAME << _) | Cert.INFO_SUBJECT_FLAG - , FIND_SUBJECT_ATTR := (Cert.COMPARE_ATTR << _) | Cert.INFO_SUBJECT_FLAG - , FIND_ISSUER_NAME := (Cert.COMPARE_NAME << _) | Cert.INFO_ISSUER_FLAG - , FIND_ISSUER_ATTR := (Cert.COMPARE_ATTR << _) | Cert.INFO_ISSUER_FLAG - , FIND_SUBJECT_STR := (Cert.COMPARE_NAME_STR_W << _) | Cert.INFO_SUBJECT_FLAG - , FIND_ISSUER_STR := (Cert.COMPARE_NAME_STR_W << _) | Cert.INFO_ISSUER_FLAG - , FIND_KEY_SPEC := Cert.COMPARE_KEY_SPEC << _ - , FIND_ENHKEY_USAGE := Cert.COMPARE_ENHKEY_USAGE << _ - , FIND_CTL_USAGE := Cert.FIND_ENHKEY_USAGE - , FIND_SUBJECT_CERT := Cert.COMPARE_SUBJECT_CERT << _ - , FIND_ISSUER_OF := Cert.COMPARE_ISSUER_OF << _ - , FIND_EXISTING := Cert.COMPARE_EXISTING << _ - , FIND_CERT_ID := Cert.COMPARE_CERT_ID << _ - , FIND_CROSS_CERT_DIST_POINTS := Cert.COMPARE_CROSS_CERT_DIST_POINTS << _ - , FIND_PUBKEY_MD5_HASH := Cert.COMPARE_PUBKEY_MD5_HASH << _ - , FIND_SUBJECT_INFO_ACCESS := Cert.COMPARE_SUBJECT_INFO_ACCESS << _ - - ; Certificate Store Provider Types (CERT_STORE_PROV_*) -static STORE_PROV_MSG := 1 - , STORE_PROV_MEMORY := 2 - , STORE_PROV_FILE := 3 - , STORE_PROV_REG := 4 - , STORE_PROV_PKCS7 := 5 - , STORE_PROV_SERIALIZED := 6 - , STORE_PROV_FILENAME_A := 7 - , STORE_PROV_FILENAME_W := 8 - , STORE_PROV_FILENAME := Cert.STORE_PROV_FILENAME_W - , STORE_PROV_SYSTEM_A := 9 - , STORE_PROV_SYSTEM_W := 10 - , STORE_PROV_SYSTEM := Cert.STORE_PROV_SYSTEM_W - , STORE_PROV_COLLECTION := 11 - , STORE_PROV_SYSTEM_REGISTRY_A := 12 - , STORE_PROV_SYSTEM_REGISTRY_W := 13 - , STORE_PROV_SYSTEM_REGISTRY := Cert.STORE_PROV_SYSTEM_REGISTRY_W - , STORE_PROV_PHYSICAL_W := 14 - , STORE_PROV_PHYSICAL := Cert.STORE_PROV_PHYSICAL_W - , STORE_PROV_LDAP_W := 16 - , STORE_PROV_LDAP := Cert.STORE_PROV_LDAP_W - , STORE_PROV_PKCS12 := 17 - - ; Certificate Store open/property flags (low-word; CERT_STORE_*) -static STORE_NO_CRYPT_RELEASE_FLAG := 0x0001 - , STORE_SET_LOCALIZED_NAME_FLAG := 0x0002 - , STORE_DEFER_CLOSE_UNTIL_LAST_FREE_FLAG := 0x0004 - , STORE_DELETE_FLAG := 0x0010 - , STORE_UNSAFE_PHYSICAL_FLAG := 0x0020 - , STORE_SHARE_STORE_FLAG := 0x0040 - , STORE_SHARE_CONTEXT_FLAG := 0x0080 - , STORE_MANIFOLD_FLAG := 0x0100 - , STORE_ENUM_ARCHIVED_FLAG := 0x0200 - , STORE_UPDATE_KEYID_FLAG := 0x0400 - , STORE_BACKUP_RESTORE_FLAG := 0x0800 - , STORE_READONLY_FLAG := 0x8000 - , STORE_OPEN_EXISTING_FLAG := 0x4000 - , STORE_CREATE_NEW_FLAG := 0x2000 - , STORE_MAXIMUM_ALLOWED_FLAG := 0x1000 - - ; Certificate System Store Flag Values (high-word; CERT_SYSTEM_STORE_*) -static SYSTEM_STORE_MASK := 0xFFFF0000 - , SYSTEM_STORE_RELOCATE_FLAG := 0x80000000 - , SYSTEM_STORE_UNPROTECTED_FLAG := 0x40000000 - ; Location of the system store: - , SYSTEM_STORE_LOCATION_MASK := 0x00FF0000 - , SYSTEM_STORE_LOCATION_SHIFT := (_ := 16) - ; Registry: HKEY_CURRENT_USER or HKEY_LOCAL_MACHINE - , SYSTEM_STORE_CURRENT_USER_ID := 1 - , SYSTEM_STORE_LOCAL_MACHINE_ID := 2 - ; Registry: HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography\Services - , SYSTEM_STORE_CURRENT_SERVICE_ID := 4 - , SYSTEM_STORE_SERVICES_ID := 5 - ; Registry: HKEY_USERS - , SYSTEM_STORE_USERS_ID := 6 - ; Registry: HKEY_CURRENT_USER\Software\Policies\Microsoft\SystemCertificates - , SYSTEM_STORE_CURRENT_USER_GROUP_POLICY_ID := 7 - ; Registry: HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\SystemCertificates - , SYSTEM_STORE_LOCAL_MACHINE_GROUP_POLICY_ID := 8 - ; Registry: HKEY_LOCAL_MACHINE\Software\Microsoft\EnterpriseCertificates - , SYSTEM_STORE_LOCAL_MACHINE_ENTERPRISE_ID := 9 - , SYSTEM_STORE_CURRENT_USER := (Cert.SYSTEM_STORE_CURRENT_USER_ID << _) - , SYSTEM_STORE_LOCAL_MACHINE := (Cert.SYSTEM_STORE_LOCAL_MACHINE_ID << _) - , SYSTEM_STORE_CURRENT_SERVICE := (Cert.SYSTEM_STORE_CURRENT_SERVICE_ID << _) - , SYSTEM_STORE_SERVICES := (Cert.SYSTEM_STORE_SERVICES_ID << _) - , SYSTEM_STORE_USERS := (Cert.SYSTEM_STORE_USERS_ID << _) - , SYSTEM_STORE_CURRENT_USER_GROUP_POLICY := (Cert.SYSTEM_STORE_CURRENT_USER_GROUP_POLICY_ID << _) - , SYSTEM_STORE_LOCAL_MACHINE_GROUP_POLICY := (Cert.SYSTEM_STORE_LOCAL_MACHINE_GROUP_POLICY_ID << _) - , SYSTEM_STORE_LOCAL_MACHINE_ENTERPRISE := (Cert.SYSTEM_STORE_LOCAL_MACHINE_ENTERPRISE_ID << _) - - ; Certificate name types (CERT_NAME_*) -static NAME_EMAIL_TYPE := 1 - , NAME_RDN_TYPE := 2 - , NAME_ATTR_TYPE := 3 - , NAME_SIMPLE_DISPLAY_TYPE := 4 - , NAME_FRIENDLY_DISPLAY_TYPE := 5 - , NAME_DNS_TYPE := 6 - , NAME_URL_TYPE := 7 - , NAME_UPN_TYPE := 8 - ; Certificate name flags - , NAME_ISSUER_FLAG := 0x00000001 - , NAME_DISABLE_IE4_UTF8_FLAG := 0x00010000 - , NAME_STR_ENABLE_PUNYCODE_FLAG := 0x00200000 - - ; dwAddDisposition values (CERT_STORE_ADD_*) -static STORE_ADD_NEW := 1 - , STORE_ADD_USE_EXISTING := 2 - , STORE_ADD_REPLACE_EXISTING := 3 - , STORE_ADD_ALWAYS := 4 - , STORE_ADD_REPLACE_EXISTING_INHERIT_PROPERTIES := 5 - , STORE_ADD_NEWER := 6 - , STORE_ADD_NEWER_INHERIT_PROPERTIES := 7 - - ; - ; Static Methods - ; - - OpenStore(pStoreProvider, dwMsgAndCertEncodingType, dwFlags, ParamType="Ptr", Param=0) - { - hCertStore := DllCall("Crypt32\CertOpenStore" - , "ptr", pStoreProvider - , "uint", dwMsgAndCertEncodingType - , "ptr", 0 ; hCryptProv - , "uint", dwFlags - , ParamType, Param) - if hCertStore - hCertStore := new this.Store(hCertStore) - return hCertStore - } - - GetStoreNames(dwFlags) - { - static cb := RegisterCallback("Cert_GetStoreNames_Callback", "F") - global Cert - DllCall("Crypt32\CertEnumSystemStore", "uint", dwFlags - , "ptr", 0, "ptr", &(names := []), "ptr", cb) - return names - } - - - ; - ; Certificate Name - ; - class Name - { - __New(Props) - { - static Fields := { - (Join, - CommonName: "CN" - LocalityName: "L" - Organization: "O" - OrganizationalUnit: "OU" - Email: "E" - Country: "C" - State: "ST" - StreetAddress: "STREET" - Title: "T" - GivenName: "G" - Initials: "I" - Surname: "SN" - Doman: "DC" - )} - static CERT_X500_NAME_STR := 3, Q := """" ; For readability. - - if IsObject(Props) - { - ; Build name string from caller-supplied object. - name_string := "" - for field_name, field_code in Fields - { - if Props.HasKey(field_name) - { - if (name_string != "") - name_string .= ";" - name_string .= field_code "=" Q RegExReplace(Props[field_name], Q, Q Q) Q - } - } - } - else - name_string := Props - - Loop 2 - { - if A_Index=1 - { ; First iteration: retrieve required size. - pbEncoded := 0 - cbEncoded := 0 - } - else - { ; Second iteration: retrieve encoded name. - this.SetCapacity("data", cbEncoded) - pbEncoded := this.GetAddress("data") - } - global Cert - if !DllCall("Crypt32\CertStrToName" - , "uint", Cert.X509_ASN_ENCODING - , "str", name_string - , "uint", CERT_X500_NAME_STR - , "ptr", 0 ; Reserved - , "ptr", pbEncoded - , "uint*", cbEncoded - , "str*", ErrorString) - { - ErrorLevel := ErrorString - return false - } - } - this.SetCapacity("blob", A_PtrSize*2) ; CERT_NAME_BLOB - NumPut(pbEncoded, NumPut(cbEncoded, this.p := this.GetAddress("blob"))) - } - } - - - ; - ; Certificate Store - ; - class Store - { - FindCertificates(dwFindFlags, dwFindType, FindParamType="ptr", FindParam=0) - { - global Cert - hStore := this.h - , dwCertEncodingType := Cert.X509_ASN_ENCODING | Cert.PKCS_7_ASN_ENCODING - , ctx := new Cert.Context(0) - , certs := [] - while ctx.p := DllCall("Crypt32\CertFindCertificateInStore" - , "ptr", hStore - , "uint", dwCertEncodingType - , "uint", dwFindFlags - , "uint", dwFindType - , FindParamType, FindParam - , "ptr", ctx.p ; If non-NULL, this context is freed. - , "ptr") - { - ; Each certificate context must be duplicated since the next - ; call will free it. - certs.Insert(ctx.Duplicate()) - } - ctx.p := 0 ; Above freed it already. - return certs - } - - AddCertificate(Certificate, dwAddDisposition) - { - if !DllCall("Crypt32\CertAddCertificateContextToStore" - , "ptr", this.h - , "ptr", Certificate.p - , "uint", dwAddDisposition - , "ptr*", pStoreContext) - return 0 - global Cert - return pStoreContext ? new Cert.Context(pStoreContext) : 0 - } - - __New(handle) - { - this.h := handle - } - - __Delete() - { - if this.h && DllCall("Crypt32\CertCloseStore", "ptr", this.h, "uint", 0) - this.h := 0 - } - - static Dispose := Cert.Store.__Delete ; Alias - } - - - ; - ; Certificate Context - ; - class Context - { - __New(ptr) - { - this.p := ptr - } - - __Delete() - { - if this.p && DllCall("Crypt32\CertFreeCertificateContext", "ptr", this.p) - this.p := 0 - } - - ; CertGetNameString - ; http://msdn.microsoft.com/en-us/library/aa376086 - GetNameString(dwType, dwFlags=0, pvTypePara=0) - { - if !this.p - return - cc := DllCall("Crypt32\CertGetNameString", "ptr", this.p, "uint", dwType, "uint", dwFlags, "ptr", pvTypePara, "ptr", 0, "uint", 0) - if cc <= 1 ; i.e. empty string. - return - VarSetCapacity(name, cc*2) - DllCall("Crypt32\CertGetNameString", "ptr", this.p, "uint", dwType, "uint", dwFlags, "ptr", pvTypePara, "str", name, "uint", cc) - return name - } - - ; CertDuplicateCertificateContext - ; http://msdn.microsoft.com/en-us/library/aa376045 - Duplicate() - { - return this.p && (p := DllCall("Crypt32\CertDuplicateCertificateContext", "ptr", this.p)) - ? new this.base(p) : p - } - - static Dispose := Cert.Context.__Delete ; Alias - } - - - ; - ; Error Detection - ; - __Get(name) - { - ListLines - MsgBox 16,, Attempt to access invalid property Cert.%name%. - Pause - } -} - - -; -; Internal -; - -Cert_GetStoreNames_Callback(pvSystemStore, dwFlags, pStoreInfo, pvReserved, pvArg) -{ - Object(pvArg).Insert(StrGet(pvSystemStore, "utf-16")) - return true -} \ No newline at end of file diff --git a/EnableUIAccess/Lib/Crypt.ahk b/EnableUIAccess/Lib/Crypt.ahk deleted file mode 100644 index 6e6770b52..000000000 --- a/EnableUIAccess/Lib/Crypt.ahk +++ /dev/null @@ -1,161 +0,0 @@ -class Crypt -{ - ; Provider Types -static PROV_RSA_FULL := 1 - , PROV_RSA_SIG := 2 - , PROV_DSS := 3 - , PROV_FORTEZZA := 4 - , PROV_MS_EXCHANGE := 5 - , PROV_SSL := 6 - , PROV_STT_MER := 7 ; <= XP - , PROV_STT_ACQ := 8 ; <= XP - , PROV_STT_BRND := 9 ; <= XP - , PROV_STT_ROOT := 10 ; <= XP - , PROV_STT_ISS := 11 ; <= XP - , PROV_RSA_SCHANNEL := 12 - , PROV_DSS_DH := 13 - , PROV_EC_ECDSA_SIG := 14 - , PROV_EC_ECNRA_SIG := 15 - , PROV_EC_ECDSA_FULL := 16 - , PROV_EC_ECNRA_FULL := 17 - , PROV_DH_SCHANNEL := 18 - , PROV_SPYRUS_LYNKS := 20 - , PROV_RNG := 21 - , PROV_INTEL_SEC := 22 - , PROV_REPLACE_OWF := 23 ; >= XP - , PROV_RSA_AES := 24 ; >= XP - - ; CryptAcquireContext - dwFlags - ; http://msdn.microsoft.com/en-us/library/aa379886 -static VERIFYCONTEXT := 0xF0000000 - , NEWKEYSET := 0x00000008 - , DELETEKEYSET := 0x00000010 - , MACHINE_KEYSET := 0x00000020 - , SILENT := 0x00000040 - , CRYPT_DEFAULT_CONTAINER_OPTIONAL := 0x00000080 - - ; CryptGenKey - dwFlag - ; http://msdn.microsoft.com/en-us/library/aa379941 -static EXPORTABLE := 0x00000001 - , USER_PROTECTED := 0x00000002 - , CREATE_SALT := 0x00000004 - , UPDATE_KEY := 0x00000008 - , NO_SALT := 0x00000010 - , PREGEN := 0x00000040 - , ARCHIVABLE := 0x00004000 - , FORCE_KEY_PROTECTION_HIGH := 0x00008000 - - ; Key Types -static AT_KEYEXCHANGE := 1 - , AT_SIGNATURE := 2 - - ; - ; METHODS - ; - - AcquireContext(Container, Provider, dwProvType, dwFlags) - { - if DllCall("Advapi32\CryptAcquireContext" - , "ptr*", hProv - , "ptr", Container ? &Container : 0 - , "ptr", Provider ? &Provider : 0 - , "uint", dwProvType - , "uint", dwFlags) - { - if (dwFlags & this.DELETEKEYSET) - ; Success, but hProv is invalid in this case. - return 1 - ; Wrap it up so it'll be released at some point. - return new this.Context(hProv) - } - return 0 - } - - IsSigned(FilePath) - { - return DllCall("Crypt32\CryptQueryObject" - , "uint", CERT_QUERY_OBJECT_FILE := 1 - , "wstr", FilePath - , "uint", CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED := 1<<10 - , "uint", CERT_QUERY_FORMAT_FLAG_BINARY := 2 - , "uint", 0 - , "uint*", dwEncoding - , "uint*", dwContentType - , "uint*", dwFormatType - , "ptr", 0 - , "ptr", 0 - , "ptr", 0) - } - - ; - ; Error Detection - ; - __Get(name) - { - ListLines - MsgBox 16,, Attempt to access invalid property Crypt.%name%. - Pause - } - - ; - ; CLASSES - ; - - class _Handle - { - __New(handle) - { - this.h := handle - } - - __Delete() - { - this.Dispose() - } - } - - class Context extends Crypt._Handle - { - GenerateKey(KeyType, KeyBitLength, dwFlags) - { - if DllCall("Advapi32\CryptGenKey" - , "ptr", this.h - , "uint", KeyType - , "uint", (KeyBitLength << 16) | dwFlags - , "ptr*", hKey) - { - global Crypt - return new Crypt.Key(hKey) - } - return 0 - } - - CreateSelfSignCertificate(NameObject, StartTime, EndTime) - { - ctx := DllCall("Crypt32\CertCreateSelfSignCertificate" - , "ptr", this.h - , "ptr", IsObject(NameObject) ? NameObject.p : NameObject - , "uint", 0, "ptr", 0, "ptr", 0 - , "ptr", IsObject(StartTime) ? StartTime.p : StartTime - , "ptr", IsObject(EndTime) ? EndTime.p : EndTime - , "ptr", 0, "ptr") - global Cert - return ctx ? new Cert.Context(ctx) : 0 - } - - Dispose() - { - if this.h && DllCall("Advapi32\CryptReleaseContext", "ptr", this.h, "uint", 0) - this.h := 0 - } - } - - class Key extends Crypt._Handle - { - Dispose() - { - if this.h && DllCall("Advapi32\CryptDestroyKey", "ptr", this.h) - this.h := 0 - } - } -} \ No newline at end of file diff --git a/EnableUIAccess/Lib/EnableUIAccess.ahk b/EnableUIAccess/Lib/EnableUIAccess.ahk new file mode 100644 index 000000000..b584efc37 --- /dev/null +++ b/EnableUIAccess/Lib/EnableUIAccess.ahk @@ -0,0 +1,271 @@ +#requires AutoHotkey v2.0 + +EnableUIAccess(ExePath) { + static CertName := "AutoHotkey" + hStore := DllCall("Crypt32\CertOpenStore", "ptr",10 ; STORE_PROV_SYSTEM_W + , "uint",0, "ptr",0, "uint",0x20000 ; SYSTEM_STORE_LOCAL_MACHINE + , "wstr","Root", "ptr") + if !hStore { + throw OSError() + } + store := CertStore(hStore) + cert := CertContext() ; Find or create certificate for signing. + while (cert.ptr := DllCall("Crypt32\CertFindCertificateInStore", "ptr",hStore + , "uint",0x10001 ; X509_ASN_ENCODING|PKCS_7_ASN_ENCODING + , "uint",0, "uint",0x80007 ; FIND_SUBJECT_STR + , "wstr", CertName, "ptr",cert.ptr, "ptr")) + && !(DllCall("Crypt32\CryptAcquireCertificatePrivateKey" + , "ptr",cert, "uint",5 ; CRYPT_ACQUIRE_CACHE_FLAG|CRYPT_ACQUIRE_COMPARE_KEY_FLAG + , "ptr",0, "ptr*", 0, "uint*", &keySpec:=0, "ptr",0) + && (keySpec & 2)) { ; AT_SIGNATURE ; Keep looking for a certificate with a private key. + } + if !cert.ptr { + cert := EnableUIAccess_CreateCert(CertName, hStore) + } + EnableUIAccess_SetManifest(ExePath) ; Set uiAccess attribute in manifest + EnableUIAccess_SignFile(ExePath, cert, CertName) ; Sign the file (otherwise uiAccess attribute is ignored) + return true +} + +EnableUIAccess_SetManifest(ExePath) { + xml := ComObject("Msxml2.DOMDocument") + xml.async := false + xml.setProperty("SelectionLanguage", "XPath") + xml.setProperty("SelectionNamespaces" + , "xmlns:v1='urn:schemas-microsoft-com:asm.v1' " + . "xmlns:v3='urn:schemas-microsoft-com:asm.v3'") + try { + if !xml.loadXML(EnableUIAccess_ReadManifest(ExePath)) { + throw Error("Invalid manifest") + } + } catch as e { + throw Error("Error loading manifest from " ExePath,, e.Message "`n @ " e.File ":" e.Line) + } + + + node := xml.selectSingleNode("/v1:assembly/v3:trustInfo/v3:security" + . "/v3:requestedPrivileges/v3:requestedExecutionLevel") + if !node ; Not AutoHotkey? + throw Error("Manifest is missing required elements") + + node.setAttribute("uiAccess", "true") + xml := RTrim(xml.xml, "`r`n") + + data := Buffer(StrPut(xml, "utf-8") - 1) + StrPut(xml, data, "utf-8") + + if !(hupd := DllCall("BeginUpdateResource", "str",ExePath, "int",false)) + throw OSError() + r := DllCall("UpdateResource", "ptr",hupd, "ptr",24, "ptr",1 + , "ushort", 1033, "ptr",data, "uint",data.size) + + ; Retry loop to work around file locks (especially by antivirus) + for delay in [0, 100, 500, 1000, 3500] { + Sleep delay + if DllCall("EndUpdateResource", "ptr",hupd, "int",!r) || !r + return + if !(A_LastError = 5 || A_LastError = 110) ; ERROR_ACCESS_DENIED || ERROR_OPEN_FAILED + break + } + throw OSError(A_LastError, "EndUpdateResource") +} + +EnableUIAccess_ReadManifest(ExePath) { + if !(hmod := DllCall("LoadLibraryEx", "str",ExePath, "ptr",0, "uint",2, "ptr")) + throw OSError() + try { + if !(hres := DllCall("FindResource", "ptr",hmod, "ptr",1, "ptr",24, "ptr")) { + throw OSError() + } + size := DllCall("SizeofResource", "ptr",hmod, "ptr",hres, "uint") + if !(hglb := DllCall("LoadResource", "ptr",hmod, "ptr",hres, "ptr")) { + throw OSError() + } + if !(pres := DllCall("LockResource", "ptr",hglb, "ptr")) { + throw OSError() + } + return StrGet(pres, size, "utf-8") + } + finally { + DllCall("FreeLibrary", "ptr",hmod) + } +} + +EnableUIAccess_CreateCert(Name, hStore) { + prov := CryptContext() ; Here Name is used as the key container name. + if !DllCall("Advapi32\CryptAcquireContext", "ptr*", prov + , "str",Name, "ptr",0, "uint",1, "uint",0) { ; PROV_RSA_FULL=1, open existing=0 + if !DllCall("Advapi32\CryptAcquireContext", "ptr*", prov + , "str",Name, "ptr",0, "uint",1, "uint",8) { ; PROV_RSA_FULL=1, CRYPT_NEWKEYSET=8 + throw OSError() + } + if !DllCall("Advapi32\CryptGenKey", "ptr",prov + , "uint",2, "uint",0x4000001, "ptr*", CryptKey()) { ; AT_SIGNATURE=2, EXPORTABLE=..01 + throw OSError() + } + } + + ; Here Name is used as the certificate subject and name. + Loop 2 { + if A_Index = 1 { + pbName := cbName := 0 + } else { + bName := Buffer(cbName), pbName := bName.ptr + } + if !DllCall("Crypt32\CertStrToName", "uint",1, "str","CN=" Name + , "uint",3, "ptr",0, "ptr",pbName, "uint*", &cbName, "ptr",0) ; X509_ASN_ENCODING=1, CERT_X500_NAME_STR=3 + throw OSError() + } + cnb := Buffer(2*A_PtrSize), NumPut("ptr",cbName, "ptr",pbName, cnb) + + ; Set expiry to 9999-01-01 12pm +0. + NumPut("short", 9999, "sort", 1, "short", 5, "short", 1, "short", 12, endTime := Buffer(16, 0)) + + StrPut("2.5.29.4", szOID_KEY_USAGE_RESTRICTION := Buffer(9),, "cp0") + StrPut("2.5.29.37", szOID_ENHANCED_KEY_USAGE := Buffer(10),, "cp0") + StrPut("1.3.6.1.5.5.7.3.3", szOID_PKIX_KP_CODE_SIGNING := Buffer(18),, "cp0") + + ; CERT_KEY_USAGE_RESTRICTION_INFO key_usage; + key_usage := Buffer(6*A_PtrSize, 0) + NumPut('ptr', 0, 'ptr', 0, 'ptr', 1, 'ptr', key_usage.ptr + 5*A_PtrSize, 'ptr', 0 + , 'uchar', (CERT_DATA_ENCIPHERMENT_KEY_USAGE := 0x10) + | (CERT_DIGITAL_SIGNATURE_KEY_USAGE := 0x80), key_usage) + + ; CERT_ENHKEY_USAGE enh_usage; + enh_usage := Buffer(3*A_PtrSize) + NumPut("ptr",1, "ptr",enh_usage.ptr + 2*A_PtrSize, "ptr",szOID_PKIX_KP_CODE_SIGNING.ptr, enh_usage) + + key_usage_data := EncodeObject(szOID_KEY_USAGE_RESTRICTION, key_usage) + enh_usage_data := EncodeObject(szOID_ENHANCED_KEY_USAGE, enh_usage) + + EncodeObject(structType, structInfo) { + encoder := DllCall.Bind("Crypt32\CryptEncodeObject", "uint",X509_ASN_ENCODING := 1 + , "ptr",structType, "ptr",structInfo) + if !encoder("ptr",0, "uint*", &enc_size := 0) + throw OSError() + enc_data := Buffer(enc_size) + if !encoder("ptr",enc_data, "uint*", &enc_size) + throw OSError() + enc_data.Size := enc_size + return enc_data + } + + ; CERT_EXTENSION extension[2]; CERT_EXTENSIONS extensions; + NumPut("ptr",szOID_KEY_USAGE_RESTRICTION.ptr, "ptr",true, "ptr",key_usage_data.size, "ptr",key_usage_data.ptr + , "ptr",szOID_ENHANCED_KEY_USAGE.ptr , "ptr",true, "ptr",enh_usage_data.size, "ptr",enh_usage_data.ptr + , extension := Buffer(8*A_PtrSize)) + NumPut("ptr",2, "ptr",extension.ptr, extensions := Buffer(2*A_PtrSize)) + + if !hCert := DllCall("Crypt32\CertCreateSelfSignCertificate" + , "ptr",prov, "ptr",cnb, "uint",0, "ptr",0 + , "ptr",0, "ptr",0, "ptr",endTime, "ptr",extensions, "ptr") { + throw OSError() + } + cert := CertContext(hCert) + + if !DllCall("Crypt32\CertAddCertificateContextToStore", "ptr",hStore + , "ptr",hCert, "uint",1, "ptr",0) { ; STORE_ADD_NEW=1 + throw OSError() + } + + return cert +} + +EnableUIAccess_DeleteCertAndKey(Name) { + ; This first call "acquires" the key container but also deletes it. + DllCall("Advapi32\CryptAcquireContext", "ptr*", 0, "str",Name + , "ptr",0, "uint",1, "uint",16) ; PROV_RSA_FULL=1, CRYPT_DELETEKEYSET=16 + if !hStore := DllCall("Crypt32\CertOpenStore", "ptr",10 ; STORE_PROV_SYSTEM_W + , "uint",0, "ptr",0, "uint",0x20000 ; SYSTEM_STORE_LOCAL_MACHINE + , "wstr", "Root", "ptr") + throw OSError() + store := CertStore(hStore) + deleted := 0 + ; Multiple certificates might be created over time as keys become inaccessible + while p := DllCall("Crypt32\CertFindCertificateInStore", "ptr",hStore + , "uint",0x10001 ; X509_ASN_ENCODING|PKCS_7_ASN_ENCODING + , "uint",0, "uint",0x80007 ; FIND_SUBJECT_STR + , "wstr", Name, "ptr",0, "ptr") { + if !DllCall("Crypt32\CertDeleteCertificateFromStore", "ptr",p) { + throw OSError() + } + deleted++ + } + return deleted +} + +class Crypt { + static IsSigned(FilePath) { + return DllCall("Crypt32\CryptQueryObject" + ,"uint" , CERT_QUERY_OBJECT_FILE := 1 + ,"wstr" , FilePath + ,"uint" , CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED := 1<<10 + ,"uint" , CERT_QUERY_FORMAT_FLAG_BINARY := 2 + ,"uint" , 0 + ,"uint*" , &dwEncoding:=0 + ,"uint*" , &dwContentType:=0 + ,"uint*" , &dwFormatType:=0 + ,"ptr" , 0 + ,"ptr" , 0 + ,"ptr" , 0) + } +} +class CryptPtrBase { + __new(p:=0) => this.ptr := p + __delete() => this.ptr && this.Dispose() +} +class CryptContext extends CryptPtrBase { + Dispose() => DllCall("Advapi32\CryptReleaseContext", "ptr",this, "uint",0) +} +class CertContext extends CryptPtrBase { + Dispose() => DllCall("Crypt32\CertFreeCertificateContext", "ptr",this) +} +class CertStore extends CryptPtrBase { + Dispose() => DllCall("Crypt32\CertCloseStore", "ptr",this, "uint",0) +} +class CryptKey extends CryptPtrBase { + Dispose() => DllCall("Advapi32\CryptDestroyKey", "ptr",this) +} + +EnableUIAccess_SignFile(ExePath, CertCtx, Name) { + file_info := struct( ; SIGNER_FILE_INFO + "ptr",A_PtrSize*3, "ptr",StrPtr(ExePath)) + dwIndex := Buffer(4, 0) ; DWORD + subject_info := struct( ; SIGNER_SUBJECT_INFO + "ptr",A_PtrSize*4, "ptr",dwIndex.ptr, "ptr",SIGNER_SUBJECT_FILE:=1, + "ptr",file_info.ptr) + cert_store_info := struct( ; SIGNER_CERT_STORE_INFO + "ptr",A_PtrSize*4, "ptr",CertCtx.ptr, "ptr",SIGNER_CERT_POLICY_CHAIN:=2) + cert_info := struct( ; SIGNER_CERT + "uint",8+A_PtrSize*2, "uint",SIGNER_CERT_STORE:=2, + "ptr",cert_store_info.ptr) + authcode_attr := struct( ; SIGNER_ATTR_AUTHCODE + "uint",8+A_PtrSize*3, "int",false, "ptr",true, "ptr",StrPtr(Name)) + sig_info := struct( ; SIGNER_SIGNATURE_INFO + "uint",8+A_PtrSize*4, "uint",CALG_SHA1:=0x8004, + "ptr",SIGNER_AUTHCODE_ATTR:=1, "ptr",authcode_attr.ptr) + + hr := DllCall("MSSign32\SignerSign" + , "ptr",subject_info, "ptr",cert_info, "ptr",sig_info + , "ptr",0, "ptr",0, "ptr",0, "ptr",0, "hresult") ; pProviderInfo pwszHttpTimeStamp psRequest pSipData + + struct(args*) => ( + args.Push(b := Buffer(args[2], 0)), + NumPut(args*), + b + ) +} + +EnableUIAccess_Verify(ExePath) { ; Verifies a signed executable file. Returns 0 on success, or a standard OS error number. + wfi := Buffer(4*A_PtrSize) ; WINTRUST_FILE_INFO + NumPut('ptr', wfi.size, 'ptr', StrPtr(ExePath), 'ptr', 0, 'ptr', 0, wfi) + NumPut('int64', 0x11d0cd4400aac56b, 'int64', 0xee95c24fc000c28c, actionID := Buffer(16)) ; WINTRUST_ACTION_GENERIC_VERIFY_V2 + + wtd := Buffer(9*A_PtrSize+16) ; WINTRUST_DATA + NumPut( + 'ptr', wtd.Size, 'ptr', 0, 'ptr', 0, 'int', WTD_UI_NONE:=2, 'int', WTD_REVOKE_NONE:=0, + 'ptr', WTD_CHOICE_FILE:=1, 'ptr', wfi.ptr, 'ptr', WTD_STATEACTION_VERIFY:=1, + 'ptr', 0, 'ptr', 0, 'int', 0, 'int', 0, 'ptr', 0, wtd + ) + return DllCall('wintrust\WinVerifyTrust', 'ptr', 0, 'ptr', actionID, 'ptr', wtd, 'int') +} diff --git a/EnableUIAccess/Lib/SignFile.ahk b/EnableUIAccess/Lib/SignFile.ahk deleted file mode 100644 index 85e1ce879..000000000 --- a/EnableUIAccess/Lib/SignFile.ahk +++ /dev/null @@ -1,52 +0,0 @@ -SignFile(File, CertCtx, Name) -{ - VarSetCapacity(wfile, 2 * StrPut(File, "utf-16")), StrPut(File, &wfile, "utf-16") - VarSetCapacity(wname, 2 * StrPut(Name, "utf-16")), StrPut(Name, &wname, "utf-16") - cert_ptr := IsObject(CertCtx) ? CertCtx.p : CertCtx - - VarSetCapacity(file_info, A_PtrSize*3, 0) ; SIGNER_FILE_INFO - NumPut(3*A_PtrSize, file_info, 0) - NumPut(&wfile, file_info, A_PtrSize) - - VarSetCapacity(dwIndex, 4, 0) - - VarSetCapacity(subject_info, A_PtrSize*4, 0) ; SIGNER_SUBJECT_INFO - NumPut(A_PtrSize*4, subject_info, 0) - NumPut(&dwIndex, subject_info, A_PtrSize) ; MSDN: "must be set to zero" in this case means "must be set to the address of a field containing zero". - NumPut(SIGNER_SUBJECT_FILE:=1, subject_info, A_PtrSize*2) - NumPut(&file_info, subject_info, A_PtrSize*3) - - VarSetCapacity(cert_store_info, A_PtrSize*4, 0) ; SIGNER_CERT_STORE_INFO - NumPut(A_PtrSize*4, cert_store_info, 0) - NumPut(cert_ptr, cert_store_info, A_PtrSize) - NumPut(SIGNER_CERT_POLICY_CHAIN:=2, cert_store_info, A_PtrSize*3) - - VarSetCapacity(cert_info, 8+A_PtrSize*2, 0) ; SIGNER_CERT - NumPut(8+A_PtrSize*2, cert_info, 0, "uint") - NumPut(SIGNER_CERT_STORE:=2, cert_info, 4, "uint") - NumPut(&cert_store_info, cert_info, 8) - - VarSetCapacity(authcode_attr, 8+A_PtrSize*3, 0) ; SIGNER_ATTR_AUTHCODE - NumPut(8+A_PtrSize*3, authcode_attr, 0, "uint") - NumPut(false, authcode_attr, 4, "int") ; fCommercial - NumPut(true, authcode_attr, 8) ; fIndividual - NumPut(&wname, authcode_attr, 8+A_PtrSize) - - VarSetCapacity(sig_info, 8+A_PtrSize*4, 0) ; SIGNER_SIGNATURE_INFO - NumPut(8+A_PtrSize*4, sig_info, 0, "uint") - NumPut(CALG_SHA1:=0x8004, sig_info, 4, "uint") - NumPut(SIGNER_AUTHCODE_ATTR:=1, sig_info, 8) - NumPut(&authcode_attr, sig_info, 8+A_PtrSize) - - hr := DllCall("MSSign32\SignerSign" - , "ptr", &subject_info - , "ptr", &cert_info - , "ptr", &sig_info - , "ptr", 0 ; pProviderInfo - , "ptr", 0 ; pwszHttpTimeStamp - , "ptr", 0 ; psRequest - , "ptr", 0 ; pSipData - , "uint") - - return 0 == (ErrorLevel := hr) -} \ No newline at end of file diff --git a/EnableUIAccess/Lib/SystemTime.ahk b/EnableUIAccess/Lib/SystemTime.ahk deleted file mode 100644 index 446e0b2ea..000000000 --- a/EnableUIAccess/Lib/SystemTime.ahk +++ /dev/null @@ -1,101 +0,0 @@ -/* - SystemTime - Wrapper for Win32 SYSTEMTIME Structure - http://msdn.microsoft.com/en-us/library/ms724950 - - Usage Examples: - - ; Create structure from string. - st := SystemTime.FromString(A_Now) - - ; Shortcut: - st := SystemTime.Now() - - ; Update values. - st.FromString(A_Now) - - ; Retrieve components. - year := st.Year - month := st.Month - weekday := st.DayOfWeek - day := st.Day - hour := st.Hour - minute := st.Minute - second := st.Second - ms := st.Milliseconds - - ; Set or perform math on component. - st.Year += 10 - - ; Create structure to receive output from DllCall. - st := new SystemTime - DllCall("GetSystemTime", "ptr", st.p) - MsgBox % st.ToString() - - ; Fill external structure. - st := SystemTime.FromPointer(externalPointer) - st.FromString(A_Now) - - ; Convert external structure to string. - MsgBox % SystemTime.ToString(externalPointer) - -*/ - -class SystemTime -{ - FromString(str) - { - if this.p - st := this - else - st := new this - if !(p := st.p) - return 0 - FormatTime wday, %str%, WDay - wday -= 1 - FormatTime str, %str%, yyyy M '%wday%' d H m s '0' - Loop Parse, str, %A_Space% - NumPut(A_LoopField, p+(A_Index-1)*2, "ushort") - return st - } - - FromPointer(pointer) - { - return { p: pointer, base: this } ; Does not call __New. - } - - ToString(st = 0) - { - if !(p := (st ? (IsObject(st) ? st.p : st) : this.p)) - return "" - VarSetCapacity(s, 28), s := SubStr("000" NumGet(p+0, "ushort"), -3) - Loop 6 - if A_Index != 2 - s .= SubStr("0" NumGet(p+A_Index*2, "ushort"), -1) - return s - } - - Now() - { - return this.FromString(A_Now) - } - - __New() - { - if !(this.SetCapacity("struct", 16)) - || !(this.p := this.GetAddress("struct")) - return 0 - NumPut(0, NumPut(0, this.p, "int64"), "int64") - } - - __GetSet(name, value="") - { - static fields := {Year:0, Month:2, DayOfWeek:4, Day:6, Hour:8 - , Minute:10, Second:12, Milliseconds:14} - if fields.HasKey(name) - return value="" - ? NumGet( this.p + fields[name], "ushort") - : NumPut(value, this.p + fields[name], "ushort") - } - static __Get := SystemTime.__GetSet - static __Set := SystemTime.__GetSet -} \ No newline at end of file diff --git a/assets/kanata.ico b/assets/kanata.ico new file mode 100644 index 000000000..80838d3d6 Binary files /dev/null and b/assets/kanata.ico differ diff --git a/assets/reload_32px.png b/assets/reload_32px.png new file mode 100644 index 000000000..63601649d Binary files /dev/null and b/assets/reload_32px.png differ diff --git a/build.rs b/build.rs index 4d53b5bab..21b17555d 100644 --- a/build.rs +++ b/build.rs @@ -1,12 +1,12 @@ fn main() -> std::io::Result<()> { - #[cfg(all(target_os = "windows", feature = "win_manifest"))] + #[cfg(all(target_os = "windows", any(feature = "win_manifest", feature = "gui")))] { windows::build()?; } Ok(()) } -#[cfg(all(target_os = "windows", feature = "win_manifest"))] +#[cfg(all(target_os = "windows", any(feature = "win_manifest", feature = "gui")))] mod windows { use indoc::formatdoc; use regex::Regex; @@ -42,14 +42,22 @@ mod windows { let manifest_str = formatdoc!( r#" - - - - - - - - + + + + + + + + true + PerMonitorV2 + + + + + + "#, version ); diff --git a/cfg_samples/tray-icon/3trans.parent.png b/cfg_samples/tray-icon/3trans.parent.png new file mode 100644 index 000000000..a199266de Binary files /dev/null and b/cfg_samples/tray-icon/3trans.parent.png differ diff --git a/cfg_samples/tray-icon/6name-match.png b/cfg_samples/tray-icon/6name-match.png new file mode 100644 index 000000000..cd0a00efa Binary files /dev/null and b/cfg_samples/tray-icon/6name-match.png differ diff --git a/cfg_samples/tray-icon/_custom-icons/s.png b/cfg_samples/tray-icon/_custom-icons/s.png new file mode 100644 index 000000000..4692a76f3 Binary files /dev/null and b/cfg_samples/tray-icon/_custom-icons/s.png differ diff --git a/cfg_samples/tray-icon/icons/1symbols.png b/cfg_samples/tray-icon/icons/1symbols.png new file mode 100644 index 000000000..f55da3329 Binary files /dev/null and b/cfg_samples/tray-icon/icons/1symbols.png differ diff --git a/cfg_samples/tray-icon/img/2Nav Num.png b/cfg_samples/tray-icon/img/2Nav Num.png new file mode 100644 index 000000000..b70b4415e Binary files /dev/null and b/cfg_samples/tray-icon/img/2Nav Num.png differ diff --git a/cfg_samples/tray-icon/license_icons.txt b/cfg_samples/tray-icon/license_icons.txt new file mode 100644 index 000000000..e45a2e224 --- /dev/null +++ b/cfg_samples/tray-icon/license_icons.txt @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2024, Fred Vatin + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/cfg_samples/tray-icon/tray-icon.kbd b/cfg_samples/tray-icon/tray-icon.kbd new file mode 100644 index 000000000..5cca181a5 --- /dev/null +++ b/cfg_samples/tray-icon/tray-icon.kbd @@ -0,0 +1,21 @@ +(defcfg + process-unmapped-keys yes ;;|no| enable processing of keys that are not in defsrc, useful if mapping a few keys in defsrc instead of most of the keys on your keyboard. Without this, the tap-hold-release and tap-hold-press actions will not activate for keys that are not in defsrc. Disabled because some keys may not work correctly if they are intercepted. E.g. rctl/altgr on Windows; see the windows-altgr configuration item above for context. + log-layer-changes yes ;;|no| overhead + tray-icon "./_custom-icons/s.png" ;; should activate for layers without icons like '5no-icn' + icon-match-layer-name yes ;;|yes| match layer name to icon files even without an explicit (icon name.ico) config +) +(defalias l1 (layer-while-held 1emoji)) +(defalias l2 (layer-while-held 2icon-quote)) +(defalias l3 (layer-while-held 3emoji_alt)) +(defalias l4 (layer-while-held 4my-lmap)) +(defalias l5 (layer-while-held 5no-icn)) +(defalias l6 (layer-while-held 6name-match)) + +(defsrc 1 2 3 4 5 6) +(deflayer (⌂ icon base.png) @l1 @l2 @l3 @l4 @l5 @l6) ;; find in the 'icon' subfolder +(deflayer (1emoji 🖻 1symbols.png) q q q q q q) ;; find in the 'icons' subfolder +(deflayer (2icon-quote 🖻 "2Nav Num.png") w w w w w w) ;; find in the 'img' subfolder +(deflayer (3emoji_alt 🖼 3trans.parent) e e e e e e) ;; find '.png' +(deflayermap (4my-lmap 🖻 "..\..\assets\kanata.ico") 1 r 2 r 3 r 4 r 5 r 6 r) ;; find in relative path +(deflayer 5no-icn t t t t t t) ;; match file name from 'tray-icon' config, whithout which would fall back to 'tray-icon.png' as it's the only valid icon matching 'tray-icon.kbd' name +(deflayer 6name-match y y y y y y) ;; uses '6name-match' with any valid extension since 'icon-match-layer-name' is set to 'yes' diff --git a/cfg_samples/tray-icon/tray-icon.png b/cfg_samples/tray-icon/tray-icon.png new file mode 100644 index 000000000..7508e5c82 Binary files /dev/null and b/cfg_samples/tray-icon/tray-icon.png differ diff --git a/docs/config.adoc b/docs/config.adoc index ba44c7679..8ec37c1fe 100644 --- a/docs/config.adoc +++ b/docs/config.adoc @@ -154,6 +154,10 @@ would be: ) ---- +A <> also allows specifying +layer icons in `+deflayer+` and `+deflayermap+` to show in the tray menu on layer activation, +see https://github.com/jtroo/kanata/blob/main/cfg_samples/tray-icon/tray-icon.kbd[example config] + ==== deflayermap An alternative method for defining a layer exists: `deflayermap`. @@ -2432,6 +2436,42 @@ they are an arbitrary length and can be very long. ) ---- +[[windows-only-tray-icon]] +=== Windows only: tray-icon +<> + +Show a custom tray icon file for a <> gui-enabled build of kanata on Windows. +Accepts either the full path (including the file name with an extension) to the icon file +or just the file name, which is then searched in the following locations: + +* Default parent folders: +** config file's, executable's +** env vars: `XDG_CONFIG_HOME`, `APPDATA` (`C:\Users\\AppData\Roaming`), `USERPROFILE` `/.config` (`C:\Users\\.config`) +* Default config subfolders: `kanata` `kanata-tray` +* Default image subfolders (optional): `icon` `img` `icons` +* Supported image file formats: `ico` `jpg` `jpeg` `png` `bmp` `dds` `tiff` + +If not specified, tries to load any icon file from the same locations with the name matching +config name with extension replaced by one of the supported ones. +See https://github.com/jtroo/kanata/blob/main/cfg_samples/tray-icon/tray-icon.kbd[example config] for more details. + +.Example: +[source] +---- +;; in a config file C:\Users\\AppData\Roaming\kanata\kanata.kbd +(defcfg + tray-icon base.png ;; will load C:\Users\\AppData\Roaming\kanata\base.png +) +---- + +[[windows-only-icon-match-layer-name]] +=== Windows only: icon-match-layer-name +<> + +Show a custom tray icon that matches the name of the active layer if it doesn't specify an explicit icon. +Otherwise <> will be used. Defaults to true. File search rules are the same as in <>. +See https://github.com/jtroo/kanata/blob/main/cfg_samples/tray-icon/tray-icon.kbd[example config] for more details. + [[using-multiple-defcfg-options]] === Using multiple defcfg options <> @@ -3438,7 +3478,7 @@ The default `kanata.exe` binary doesn't work in elevated windows (run with admin e.g., `Control Panel`. However, you can use AutoHotkey's "EnableUIAccess" script to self-sign the binary, move it to "Program Files", then launching kanata from there will also work in these elevated windows. See https://github.com/jtroo/kanata/blob/main/EnableUIAccess[EnableUIAccess] folder with the script -and its requires libraries (needs https://www.autohotkey.com/download/ahk-install.exe[AutoHotkey v1] installed) +and its required libraries (needs https://www.autohotkey.com/download/[AutoHotkey v2] installed) If compiling yourself, you should add the feature flag `win_manifest` to enable the use of the `EnableUIAccess` script: @@ -3447,6 +3487,30 @@ to enable the use of the `EnableUIAccess` script: cargo build --win_manifest ``` +[[windows-only-win-tray]] +=== Windows only: win-tray +<> + +Kanata can be compiled as a Windows GUI tray app with the feature flag `gui`. +This can simplify launching the app on user login by placing a `.lnk` +at `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`, show custom icon indicator per config + + +image:https://github.com/jtroo/kanata/blob/main/docs/win-tray/win-tray-screen.png[icon indicator per config,477,129] + +as well as dynamic icon indicator per layer (might need to click on the gif below to play) + +image:https://github.com/jtroo/kanata/blob/main/docs/win-tray/win-tray-layer-change.gif[icon indicator per layer,33,35,opts=autoplay] + +(see <>). It also supports (re)loading configs. + +Currently the only configuration supported is tray icon per profile, all other configuration should +be done by passing cli flags in the `Target` field of `.lnk`, e.g., `"C:\Program Files\kanata\kanata.exe" -d -n` +to launch kanata without a delay in a debug mode + +When launched from a command line, the app outputs log to the console, but otherwise the logs are currently +only available via an app capable of viewing `OutputDebugString` debugs, e.g., https://github.com/smourier/TraceSpy[TraceSpy]. + [[test-your-config]] === Test your config <> diff --git a/docs/win-tray/win-tray-layer-change.gif b/docs/win-tray/win-tray-layer-change.gif new file mode 100644 index 000000000..62b1f4a5d Binary files /dev/null and b/docs/win-tray/win-tray-layer-change.gif differ diff --git a/docs/win-tray/win-tray-screen.png b/docs/win-tray/win-tray-screen.png new file mode 100644 index 000000000..fe7c5aa67 Binary files /dev/null and b/docs/win-tray/win-tray-screen.png differ diff --git a/justfile b/justfile index 6f0fd439a..b20c9a36e 100644 --- a/justfile +++ b/justfile @@ -33,6 +33,12 @@ test: fmt: cargo fmt --all +guic: + cargo check --features=gui +guif: + cargo fmt --all + cargo clippy --all --fix --features=gui -- -D warnings + use_cratesio_deps: sed -i 's/^# \(kanata-\(keyberon\|parser\|tcp-protocol\) = ".*\)$/\1/' Cargo.toml parser/Cargo.toml sed -i 's/^\(kanata-\(keyberon\|parser\|tcp-protocol\) = .*path.*\)$/# \1/' Cargo.toml parser/Cargo.toml diff --git a/keyberon/src/multikey_buffer.rs b/keyberon/src/multikey_buffer.rs index b36c3d741..77404f7ef 100644 --- a/keyberon/src/multikey_buffer.rs +++ b/keyberon/src/multikey_buffer.rs @@ -67,7 +67,8 @@ impl<'a, T> MultiKeyBuffer<'a, T> { /// # Safety /// /// The program should not have any references to the inner buffer before calling. - /// The program should not mutate the buffer after calling this function until after the returned reference is dropped. + /// The program should not mutate the buffer after calling this function until after the + /// returned reference is dropped. pub(crate) unsafe fn get_ref(&self) -> &'a Action<'a, T> { *self.ac = Action::NoOp; *self.ptr = slice::from_raw_parts(self.buf.as_ptr(), self.size); diff --git a/parser/Cargo.toml b/parser/Cargo.toml index 23c038144..e9daed839 100644 --- a/parser/Cargo.toml +++ b/parser/Cargo.toml @@ -31,3 +31,4 @@ bytemuck = "1.15.0" [features] cmd = [] interception_driver = [] +gui = [] diff --git a/parser/src/cfg/defcfg.rs b/parser/src/cfg/defcfg.rs index afbf49b37..85ca2f151 100644 --- a/parser/src/cfg/defcfg.rs +++ b/parser/src/cfg/defcfg.rs @@ -53,6 +53,10 @@ pub struct CfgOptions { pub windows_interception_keyboard_hwids: Option>, #[cfg(any(target_os = "macos", target_os = "unknown"))] pub macos_dev_names_include: Option>, + #[cfg(all(any(target_os = "windows", target_os = "unknown"), feature = "gui"))] + pub tray_icon: Option, + #[cfg(all(any(target_os = "windows", target_os = "unknown"), feature = "gui"))] + pub icon_match_layer_name: bool, } impl Default for CfgOptions { @@ -105,6 +109,10 @@ impl Default for CfgOptions { windows_interception_keyboard_hwids: None, #[cfg(any(target_os = "macos", target_os = "unknown"))] macos_dev_names_include: None, + #[cfg(all(any(target_os = "windows",target_os = "unknown"), feature = "gui"))] + tray_icon: None, + #[cfg(all(any(target_os = "windows",target_os = "unknown"), feature = "gui"))] + icon_match_layer_name: true, } } } @@ -389,6 +397,28 @@ pub fn parse_defcfg(expr: &[SExpr]) -> Result { cfg.macos_dev_names_include = Some(dev_names); } } + "tray-icon" => { + #[cfg(all( + any(target_os = "windows", target_os = "unknown"), + feature = "gui" + ))] + { + let icon_path = sexpr_to_str_or_err(val, label)?; + if icon_path.is_empty() { + log::warn!("tray-icon is empty"); + } + cfg.tray_icon = Some(icon_path.to_string()); + } + } + "icon-match-layer-name" => { + #[cfg(all( + any(target_os = "windows", target_os = "unknown"), + feature = "gui" + ))] + { + cfg.icon_match_layer_name = parse_defcfg_val_bool(val, label)? + } + } "process-unmapped-keys" => { cfg.process_unmapped_keys = parse_defcfg_val_bool(val, label)? diff --git a/parser/src/cfg/layer_opts.rs b/parser/src/cfg/layer_opts.rs new file mode 100644 index 000000000..872999190 --- /dev/null +++ b/parser/src/cfg/layer_opts.rs @@ -0,0 +1,49 @@ +use crate::cfg::*; +use crate::*; + +pub(crate) const DEFLAYER_ICON: [&str; 3] = ["icon", "🖻", "🖼"]; +pub(crate) type LayerIcons = HashMap>; + +pub fn parse_layer_opts(list: &[SExpr]) -> Result> { + let mut layer_opts: HashMap = HashMap::default(); + let mut opts = list.chunks_exact(2); + for kv in opts.by_ref() { + let key_expr = &kv[0]; + let val_expr = &kv[1]; + // Read k-v pairs from the configuration + // todo: add hashmap for future options, currently only parse icons + let opt_key = key_expr.atom(None) + .ok_or_else(|| anyhow_expr!(key_expr, "No lists are allowed in {DEFLAYER} options")) + .and_then(|opt_key| { + if DEFLAYER_ICON.iter().any(|&i| i == opt_key) { + if layer_opts.contains_key(DEFLAYER_ICON[0]) { + // separate dupe check since multi-keys are stored + // with one "canonical" repr, so '🖻' → 'icon' + // and this info will be lost after the loop + bail_expr!( + key_expr, + "Duplicate option found in {DEFLAYER}: {opt_key}, one of {DEFLAYER_ICON:?} already exists" + ); + } + Ok(DEFLAYER_ICON[0]) + } else { + bail_expr!(key_expr, "Invalid option in {DEFLAYER}: {opt_key}, expected one of {DEFLAYER_ICON:?}") + } + })?; + if layer_opts.contains_key(opt_key) { + bail_expr!(key_expr, "Duplicate option found in {DEFLAYER}: {opt_key}"); + } + let opt_val = val_expr.atom(None).ok_or_else(|| { + anyhow_expr!( + val_expr, + "No lists are allowed in {DEFLAYER}'s option values" + ) + })?; + layer_opts.insert(opt_key.to_owned(), opt_val.to_owned()); + } + let rem = opts.remainder(); + if !rem.is_empty() { + bail_expr!(&rem[0], "This option is missing a value."); + } + Ok(layer_opts) +} diff --git a/parser/src/cfg/mod.rs b/parser/src/cfg/mod.rs index dd8f18440..0295ec86d 100755 --- a/parser/src/cfg/mod.rs +++ b/parser/src/cfg/mod.rs @@ -50,6 +50,9 @@ pub use key_override::*; mod custom_tap_hold; use custom_tap_hold::*; +pub mod layer_opts; +use layer_opts::*; + pub mod list_actions; use list_actions::*; @@ -181,7 +184,8 @@ macro_rules! anyhow_span { pub struct FileContentProvider<'a> { /// A function to load content of a file from a filepath. - /// Optionally, it could implement caching and a mechanism preventing "file" and "./file" from loading twice. + /// Optionally, it could implement caching and a mechanism preventing "file" and "./file" + /// from loading twice. get_file_content_fn: &'a mut dyn FnMut(&Path) -> std::result::Result, } @@ -319,6 +323,7 @@ pub type MappedKeys = HashSet; pub struct LayerInfo { pub name: String, pub cfg_text: String, + pub icon: Option, } #[allow(clippy::type_complexity)] // return type is not pub @@ -486,7 +491,8 @@ fn expand_includes( ) }; let include_file_path = spanned_filepath.t.trim_matches('"'); - let file_content = file_content_provider.get_file_content(Path::new(include_file_path)).map_err(|e| anyhow_span!(spanned_filepath, "{e}"))?; + let file_content = file_content_provider.get_file_content(Path::new(include_file_path)) + .map_err(|e| anyhow_span!(spanned_filepath, "{e}"))?; let tree = sexpr::parse(&file_content, include_file_path)?; acc.extend(tree); @@ -648,7 +654,7 @@ pub fn parse_cfg_raw_string( bail!("No deflayer expressions exist. At least one layer must be defined.") } - let layer_idxs = parse_layer_indexes(&layer_exprs, mapping_order.len())?; + let (layer_idxs, layer_icons) = parse_layer_indexes(&layer_exprs, mapping_order.len())?; let mut sorted_idxs: Vec<(&String, &usize)> = layer_idxs.iter().map(|tuple| (tuple.0, tuple.1)).collect(); @@ -681,7 +687,11 @@ pub fn parse_cfg_raw_string( let layer_info: Vec = layer_names .into_iter() .zip(layer_strings) - .map(|(name, cfg_text)| LayerInfo { name, cfg_text }) + .map(|(name, cfg_text)| LayerInfo { + name: name.clone(), + cfg_text, + icon: layer_icons.get(&name).unwrap_or(&None).clone(), + }) .collect(); let defsrc_layer = create_defsrc_layer(); @@ -1038,8 +1048,12 @@ type Aliases = HashMap; /// - All layers have the same number of items as the defsrc, /// - There are no duplicate layer names /// - Parentheses weren't used directly or kmonad-style escapes for parentheses weren't used. -fn parse_layer_indexes(exprs: &[SpannedLayerExprs], expected_len: usize) -> Result { +fn parse_layer_indexes( + exprs: &[SpannedLayerExprs], + expected_len: usize, +) -> Result<(LayerIndexes, LayerIcons)> { let mut layer_indexes = HashMap::default(); + let mut layer_icons = HashMap::default(); for (i, expr_type) in exprs.iter().enumerate() { let (mut subexprs, expr, do_element_count_check) = match expr_type { SpannedLayerExprs::DefsrcMapping(e) => { @@ -1052,13 +1066,26 @@ fn parse_layer_indexes(exprs: &[SpannedLayerExprs], expected_len: usize) -> Resu let layer_expr = subexprs.next().ok_or_else(|| { anyhow_span!(expr, "deflayer requires a name and {expected_len} item(s)") })?; - let layer_name = match expr_type { - SpannedLayerExprs::DefsrcMapping(_) => layer_expr - .atom(None) - .ok_or_else(|| { - anyhow_expr!(layer_expr, "layer name after {DEFLAYER} must be a string") - })? - .to_owned(), + let (layer_name, icon) = match expr_type { + SpannedLayerExprs::DefsrcMapping(_) => match layer_expr { + SExpr::Atom(name_span) => (name_span.t.to_owned(), None), + SExpr::List(name_opts_span) => { + let list = &name_opts_span.t; + let name = list.first().ok_or_else(|| anyhow_span!( + name_opts_span, + "deflayer requires a string name within this pair of parenthesis (or a string name without any)" + ))? + .atom(None).ok_or_else(|| anyhow_expr!( + layer_expr, + "layer name after {DEFLAYER} must be a string when enclosed within one pair of parentheses" + ))?; + let layer_opts = parse_layer_opts(&list[1..])?; + let icon = layer_opts + .get(DEFLAYER_ICON[0]) + .map(|icon_s| icon_s.trim_matches('"').to_owned()); + (name.to_owned(), icon) + } + }, SpannedLayerExprs::CustomMapping(_) => { let list = layer_expr .list(None) @@ -1069,13 +1096,19 @@ fn parse_layer_indexes(exprs: &[SpannedLayerExprs], expected_len: usize) -> Resu ) })? .to_owned(); - if list.len() != 1 { - bail_expr!( + let name = list.first() + .and_then(|s| s.atom(None)) + .ok_or_else(|| anyhow_expr!( layer_expr, "layer name after {DEFLAYER_MAPPED} must be a string within one pair of parentheses" - ); - } - list[0].atom(None).ok_or_else(|| anyhow_expr!(layer_expr, "layer name after {DEFLAYER_MAPPED} must be a string within one pair of parentheses"))?.to_owned() + ))?; + + // add hashmap for future options, currently only parse icons + let layer_opts = parse_layer_opts(&list[1..])?; + let icon = layer_opts + .get(DEFLAYER_ICON[0]) + .map(|icon_s| icon_s.trim_matches('"').to_owned()); + (name.to_owned(), icon) } }; if layer_indexes.contains_key(&layer_name) { @@ -1119,10 +1152,11 @@ fn parse_layer_indexes(exprs: &[SpannedLayerExprs], expected_len: usize) -> Resu ) } } - layer_indexes.insert(layer_name, i); + layer_indexes.insert(layer_name.clone(), i); + layer_icons.insert(layer_name, icon); } - Ok(layer_indexes) + Ok((layer_indexes, layer_icons)) } #[derive(Debug, Clone)] @@ -2053,7 +2087,8 @@ static KEYMODI: &[(&str, KeyCode)] = &[ ("RA-", KeyCode::RAlt), ("⎇›", KeyCode::RAlt), ("⌥›", KeyCode::RAlt), - ("⎈", KeyCode::LCtrl), // Shorter indicators should be at the end to only get matched after indicators with sides have had a chance + ("⎈", KeyCode::LCtrl), // Shorter indicators should be at the end to only get matched after + // indicators with sides have had a chance ("⌥", KeyCode::LAlt), ("⎇", KeyCode::LAlt), ("◆", KeyCode::LGui), @@ -2940,18 +2975,8 @@ fn parse_layers( } } let rem = pairs.remainder(); - match rem.len() { - 0 => {} - 1 => { - bail_expr!( - &rem[0], - "an input must be followed by a map string and an action" - ); - } - 2 => { - bail_expr!(&rem[1], "map string must be followed by an action"); - } - _ => unreachable!(), + if !rem.is_empty() { + bail_expr!(&rem[0], "input must by followed by an action"); } } } @@ -3145,7 +3170,8 @@ fn parse_sequence_keys(exprs: &[SExpr], s: &ParserState) -> Result> { ); } if *pressed != KEY_OVERLAP { - // Note: key overlap item is special and goes at the end, not the beginning + // Note: key overlap item is special and goes at the end, + // not the beginning seq.push(seq_num); } } diff --git a/parser/src/cfg/tests.rs b/parser/src/cfg/tests.rs index 27b459602..a71e7303b 100644 --- a/parser/src/cfg/tests.rs +++ b/parser/src/cfg/tests.rs @@ -1288,6 +1288,8 @@ fn parse_all_defcfg() { linux-unicode-u-code v linux-unicode-termination space linux-x11-repeat-delay-rate 400,50 + tray-icon symbols.ico + icon-match-layer-name no windows-altgr add-lctl-release windows-interception-mouse-hwid "70, 0, 60, 0" windows-interception-mouse-hwids ("0, 0, 0" "1, 1, 1") @@ -1845,3 +1847,27 @@ fn parse_defseq_overlap_too_many() { .map_err(|e| eprintln!("{:?}", miette::Error::from(e))) .expect_err("fails"); } + +#[test] +fn parse_layer_opts_icon() { + let _lk = lock(&CFG_PARSE_LOCK); + new_from_file(&std::path::PathBuf::from("./test_cfgs/icon_good.kbd")).unwrap(); +} + +#[test] +fn disallow_dupe_layer_opts_icon_layernonmap() { + let _lk = lock(&CFG_PARSE_LOCK); + new_from_file(&std::path::PathBuf::from("./test_cfgs/icon_bad_dupe.kbd")) + .map(|_| ()) + .expect_err("fails"); +} + +#[test] +fn disallow_dupe_layer_opts_icon_layermap() { + let source = " +(defcfg) +(defsrc) +(deflayermap (base icon base.png 🖻 n.ico) 0 0) +"; + parse_cfg(source).map(|_| ()).expect_err("fails"); +} diff --git a/parser/test_cfgs/icon_bad_dupe.kbd b/parser/test_cfgs/icon_bad_dupe.kbd new file mode 100644 index 000000000..0fa0c83cc --- /dev/null +++ b/parser/test_cfgs/icon_bad_dupe.kbd @@ -0,0 +1,4 @@ +;; This config file is invalid and should be rejected +(defcfg) +(defsrc 1) +(deflayer (base icon base.png 🖻 n.ico ) 1) diff --git a/parser/test_cfgs/icon_good.kbd b/parser/test_cfgs/icon_good.kbd new file mode 100644 index 000000000..668e0e686 --- /dev/null +++ b/parser/test_cfgs/icon_good.kbd @@ -0,0 +1,7 @@ +(defcfg) +(defsrc 1) +(deflayer (base icon base.png ) 1) +(deflayer (1emoji 🖻 1symbols.png ) 1) +(deflayer (2icon-quote 🖻 "2Nav Num.png" ) 1) +(deflayer (3emoji_alt 🖼 3trans.parent ) 1) +(deflayermap (4layermap 🖼 3trans.parent ) 0 0) diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 000000000..ddf5257f4 --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,12 @@ +pub mod win; +pub use win::*; +pub mod win_nwg_ext; +pub use win_dbg_logger as log_win; +pub use win_dbg_logger::WINDBG_LOGGER; +pub use win_nwg_ext::*; + +use crate::*; +use parking_lot::Mutex; +use std::sync::{Arc, OnceLock}; +pub static CFG: OnceLock>> = OnceLock::new(); +pub static GUI_TX: OnceLock = OnceLock::new(); diff --git a/src/gui/win.rs b/src/gui/win.rs new file mode 100644 index 000000000..adea34bf4 --- /dev/null +++ b/src/gui/win.rs @@ -0,0 +1,1000 @@ +use crate::Kanata; +use anyhow::{bail, Context, Result}; +use core::cell::RefCell; +use log::Level::*; + +use native_windows_gui as nwg; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::env::{current_exe, var_os}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::gui::win_nwg_ext::{BitmapEx, MenuEx, MenuItemEx}; +use kanata_parser::cfg; +use nwg::{ControlHandle, NativeUi}; +use std::sync::Arc; + +trait PathExt { + fn add_ext(&mut self, ext_o: impl AsRef); +} +impl PathExt for PathBuf { + fn add_ext(&mut self, ext_o: impl AsRef) { + match self.extension() { + Some(ext) => { + let mut ext = ext.to_os_string(); + ext.push("."); + ext.push(ext_o.as_ref()); + self.set_extension(ext) + } + None => self.set_extension(ext_o.as_ref()), + }; + } +} + +#[derive(Default, Debug, Clone)] +pub struct SystemTrayData { + pub tooltip: String, + pub cfg_p: Vec, + pub cfg_icon: Option, + pub layer0_name: String, + pub layer0_icon: Option, + pub icon_match_layer_name: bool, +} +#[derive(Default)] +pub struct SystemTray { + pub app_data: RefCell, + /// Store dynamically created tray menu items + pub tray_item_dyn: RefCell>, + /// Store dynamically created tray menu items' handlers + pub handlers_dyn: RefCell>, + /// Store dynamically created icons to not load them from a file every time + pub icon_dyn: RefCell>>, + /// Store dynamically created icons to not load them from a file every time + /// (bitmap format needed to set MenuItem's icons) + pub img_dyn: RefCell>>, + /// Store 'icon_dyn' hashmap key for the currently active icon ('cfg_path:layer_name' format) + pub icon_active: RefCell>, + /// Store embedded-in-the-binary resources like icons not to load them from a file + pub embed: nwg::EmbedResource, + pub icon: nwg::Icon, + pub window: nwg::MessageWindow, + pub layer_notice: nwg::Notice, + pub tray: nwg::TrayNotification, + pub tray_menu: nwg::Menu, + pub tray_1cfg_m: nwg::Menu, + pub tray_2reload: nwg::MenuItem, + pub tray_3exit: nwg::MenuItem, + pub img_reload: nwg::Bitmap, + pub img_exit: nwg::Bitmap, +} +pub fn get_appdata() -> Option { + var_os("APPDATA").map(PathBuf::from) +} +pub fn get_user_home() -> Option { + var_os("USERPROFILE").map(PathBuf::from) +} +pub fn get_xdg_home() -> Option { + var_os("XDG_CONFIG_HOME").map(PathBuf::from) +} + +const CFG_FD: [&str; 3] = ["", "kanata", "kanata-tray"]; // blank "" allow checking directly for + // user passed values +const ASSET_FD: [&str; 4] = ["", "icon", "img", "icons"]; +const IMG_EXT: [&str; 7] = ["ico", "jpg", "jpeg", "png", "bmp", "dds", "tiff"]; +const PRE_LAYER: &str = "\n🗍: "; // : invalid path marker, so should be safe to use as a separator +use crate::gui::{CFG, GUI_TX}; + +pub fn send_gui_notice() { + if let Some(gui_tx) = GUI_TX.get() { + gui_tx.notice(); + } else { + error!("no GUI_TX to notify GUI thread of layer changes"); + } +} + +/// Find an icon file that matches a given config icon name for a layer `lyr_icn` or a layer name +/// `lyr_nm` (if `match_name` is `true`) or a given config icon name for the whole config `cfg_p` +/// or a config file name at various locations (where config file is, where executable is, +/// in user config folders) +fn get_icon_p( + lyr_icn: S1, + lyr_nm: S2, + cfg_icn: S3, + cfg_p: P, + match_name: &bool, +) -> Option +where + S1: AsRef, + S2: AsRef, + S3: AsRef, + P: AsRef, +{ + get_icon_p_impl( + lyr_icn.as_ref(), + lyr_nm.as_ref(), + cfg_icn.as_ref(), + cfg_p.as_ref(), + match_name, + ) +} + +fn get_icon_p_impl( + lyr_icn: &str, + lyr_nm: &str, + cfg_icn: &str, + p: &Path, + match_name: &bool, +) -> Option { + trace!( + "lyr_icn={lyr_icn} lyr_nm={lyr_nm} cfg_icn={cfg_icn} cfg_p={p:?} match_name={match_name}" + ); + let mut icon_file = PathBuf::new(); + let blank_p = Path::new(""); + let lyr_icn_p = Path::new(&lyr_icn); + let lyr_nm_p = Path::new(&lyr_nm); + let cfg_icn_p = Path::new(&cfg_icn); + let cfg_stem = &p.file_stem().unwrap_or_else(|| OsStr::new("")); + let cfg_name = &p.file_name().unwrap_or_else(|| OsStr::new("")); + let f_name = [ + lyr_icn_p.as_os_str(), + if *match_name { + lyr_nm_p.as_os_str() + } else { + OsStr::new("") + }, + cfg_icn_p.as_os_str(), + cfg_stem, + cfg_name, + ] + .into_iter(); + let f_ext = [ + lyr_icn_p.extension(), + if *match_name { + lyr_nm_p.extension() + } else { + None + }, + cfg_icn_p.extension(), + None, + None, + ]; + let pre_p = p.parent().unwrap_or_else(|| Path::new("")); + let cur_exe = current_exe().unwrap_or_else(|_| PathBuf::new()); + let xdg_cfg = get_xdg_home().unwrap_or_default(); + let app_data = get_appdata().unwrap_or_default(); + let mut user_cfg = get_user_home().unwrap_or_default(); + user_cfg.push(".config"); + let parents = [ + Path::new(""), + pre_p, + &cur_exe, + &xdg_cfg, + &app_data, + &user_cfg, + ]; // empty path to allow no prefixes when icon path is explictily set in case it's a full + // path already + + for (i, nm) in f_name.enumerate() { + trace!("{}nm={:?}", "", nm); + if nm.is_empty() { + trace!("no file name to test, skip"); + continue; + } + let mut is_full_p = false; + if nm == lyr_icn_p { + is_full_p = true + }; // user configs can have full paths, so test them even if all parent folders are emtpy + if nm == cfg_icn_p { + is_full_p = true + }; + let icn_ext = &f_ext[i] + .unwrap_or_else(|| OsStr::new("")) + .to_string_lossy() + .to_string(); + let is_icn_ext_valid = if !IMG_EXT.iter().any(|&i| i == icn_ext) && f_ext[i].is_some() { + warn!( + "user icon extension \"{}\" might be invalid (or just not an extension)!", + icn_ext + ); + false + } else { + trace!("icn_ext={:?}", icn_ext); + true + }; + 'p: for p_par in parents { + trace!("{}p_par={:?}", " ", p_par); + if p_par == blank_p && !is_full_p { + trace!("blank parent for non-user, skip"); + continue; + } + for p_kan in CFG_FD { + trace!("{}p_kan={:?}", " ", p_kan); + for p_icn in ASSET_FD { + trace!("{}p_icn={:?}", " ", p_icn); + for ext in IMG_EXT { + trace!("{} ext={:?}", " ", ext); + if p_par != blank_p { + icon_file.push(p_par); + } // folders + if !p_kan.is_empty() { + icon_file.push(p_kan); + } + if !p_icn.is_empty() { + icon_file.push(p_icn); + } + if !nm.is_empty() { + icon_file.push(nm); + } + if !is_full_p { + icon_file.set_extension(ext); // no icon name passed, iterate extensions + } else if !is_icn_ext_valid { + icon_file.add_ext(ext); + } else { + trace!("skip ext"); + } // replace invalid icon extension + trace!("testing icon file {:?}", icon_file); + if !icon_file.is_file() { + icon_file.clear(); + if p_par == blank_p && p_kan.is_empty() && p_icn.is_empty() && is_full_p + { + trace!("skipping further sub-iters on an empty parent with user config {:?}",nm); + continue 'p; + } + } else { + debug!("✓ found icon file: {}", icon_file.display().to_string()); + return Some(icon_file.display().to_string()); + } + } + } + } + } + } + debug!("✗ no icon file found"); + None +} + +fn set_menu_item_cfg_icon( + menu_item: &mut nwg::MenuItem, + cfg_icon_s: &str, + cfg_p: &PathBuf, +) -> Option { + if let Some(ico_p) = get_icon_p("", "", cfg_icon_s, cfg_p, &false) { + let cfg_pkey_s = cfg_p.display().to_string(); + let mut cfg_icon_bitmap = Default::default(); + if let Ok(()) = nwg::Bitmap::builder() + .source_file(Some(&ico_p)) + .strict(false) + .size(Some((24, 24))) + .build(&mut cfg_icon_bitmap) + { + debug!("✓ main 0 config: using icon for {}", cfg_pkey_s); + menu_item.set_bitmap(Some(&cfg_icon_bitmap)); + return Some(cfg_icon_bitmap); + } else { + debug!( + "✗ main 0 icon ✓ icon path, will be using DEFAULT icon for {:?}", + cfg_p + ); + } + } + menu_item.set_bitmap(None); + None +} + +impl SystemTray { + fn show_menu(&self) { + self.update_tray_icon_cfg_group(false); + let (x, y) = nwg::GlobalCursor::position(); + self.tray_menu.popup(x, y); + } + /// Add a ✓ (or highlight the icon) to the currently active config. + /// Runs on opening of the list of configs menu + fn update_tray_icon_cfg( + &self, + menu_item_cfg: &mut nwg::MenuItem, + cfg_p: &PathBuf, + is_active: bool, + ) -> Result<()> { + let mut img_dyn = self.img_dyn.borrow_mut(); + if img_dyn.contains_key(cfg_p) { + // check if menu group icon needs to be updated to match active + if is_active { + if let Some(cfg_icon_bitmap) = img_dyn.get(cfg_p) { + self.tray_1cfg_m.set_bitmap(cfg_icon_bitmap.as_ref()); + } + } + } else { + trace!("config menu item icon missing, read config and add it (or nothing) {cfg_p:?}"); + if let Ok(cfg) = cfg::new_from_file(cfg_p) { + if let Some(cfg_icon_s) = cfg.options.tray_icon { + debug!("loaded config without a tray icon {cfg_p:?}"); + if let Some(cfg_icon_bitmap) = + set_menu_item_cfg_icon(menu_item_cfg, &cfg_icon_s, cfg_p) + { + if is_active { + self.tray_1cfg_m.set_bitmap(Some(&cfg_icon_bitmap)); + } // update currently active config's icon in the combo menu + debug!("✓set icon {cfg_p:?}"); + let _ = img_dyn.insert(cfg_p.clone(), Some(cfg_icon_bitmap)); + } else { + bail!("✗couldn't get a valid icon") + } + } else { + bail!("✗icon not configured") + } + } else { + bail!("✗couldn't load config") + } + } + Ok(()) + } + fn update_tray_icon_cfg_group(&self, force: bool) { + if let Some(cfg) = CFG.get() { + if let Some(k) = cfg.try_lock() { + let idx_cfg = k.cur_cfg_idx; + let mut tray_item_dyn = self.tray_item_dyn.borrow_mut(); + let h_cfg_i = &mut tray_item_dyn[idx_cfg]; + let is_check = h_cfg_i.checked(); + if !is_check || force { + let cfg_p = &k.cfg_paths[idx_cfg]; + debug!( + "✗ mismatch idx_cfg={idx_cfg:?} {} {:?} cfg_p={cfg_p:?}", + if is_check { "✓" } else { "✗" }, + h_cfg_i.handle + ); + h_cfg_i.set_checked(true); + if let Err(e) = self.update_tray_icon_cfg(h_cfg_i, cfg_p, true) { + debug!("{e:?} {cfg_p:?}"); + let mut img_dyn = self.img_dyn.borrow_mut(); + img_dyn.insert(cfg_p.clone(), None); + self.tray_1cfg_m.set_bitmap(None); // can't update menu, so remove combo + // menu icon + }; + } else { + debug!("gui cfg selection matches active config"); + }; + } else { + debug!("✗ kanata config is locked, can't get current config (likely the gui changed the layer and is still holding the lock, it will update the icon)"); + } + }; + } + fn check_active(&self) { + if let Some(cfg) = CFG.get() { + let k = cfg.lock(); + let idx_cfg = k.cur_cfg_idx; + let mut tray_item_dyn = self.tray_item_dyn.borrow_mut(); + for (i, h_cfg_i) in tray_item_dyn.iter_mut().enumerate() { + // 1 if missing an icon, read config to get one + let cfg_p = &k.cfg_paths[i]; + trace!(" →→→→ i={i:?} {:?} cfg_p={cfg_p:?}", h_cfg_i.handle); + let is_active = i == idx_cfg; + if let Err(e) = self.update_tray_icon_cfg(h_cfg_i, cfg_p, is_active) { + debug!("{e:?} {cfg_p:?}"); + let mut img_dyn = self.img_dyn.borrow_mut(); + img_dyn.insert(cfg_p.clone(), None); + if is_active { + self.tray_1cfg_m.set_bitmap(None); + } // update currently active config's icon in the combo menu + }; + // 2 if wrong GUI checkmark, correct it + if h_cfg_i.checked() && !is_active { + debug!("uncheck i{} act{}", i, idx_cfg); + h_cfg_i.set_checked(false); + } + if !h_cfg_i.checked() && is_active { + debug!(" check i{} act{}", i, idx_cfg); + h_cfg_i.set_checked(true); + } + } + } else { + error!("no CFG var that contains active kanata config"); + }; + } + /// Reload config file, currently active (`i=None`) or matching a given `i` index + fn reload_cfg(&self, i: Option) -> Result<()> { + use nwg::TrayNotificationFlags as f_tray; + let mut msg_title = "".to_string(); + let mut msg_content = "".to_string(); + let mut flags = f_tray::empty(); + if let Some(cfg) = CFG.get() { + let mut k = cfg.lock(); + let paths = &k.cfg_paths; + let idx_cfg = match i { + Some(idx) => { + if idx < paths.len() { + idx + } else { + error!( + "Invalid config index {} while kanata has only {} configs loaded", + idx + 1, + paths.len() + ); + k.cur_cfg_idx + } + } + None => k.cur_cfg_idx, + }; + let path_cur = &paths[idx_cfg]; + let path_cur_s = path_cur.display().to_string(); + let path_cur_cc = path_cur.clone(); + msg_content += &path_cur_s; + let cfg_name = &path_cur + .file_name() + .unwrap_or_else(|| OsStr::new("")) + .to_string_lossy() + .to_string(); + if log_enabled!(Debug) { + let cfg_icon = &k.tray_icon; + let cfg_icon_s = cfg_icon.clone().unwrap_or("✗".to_string()); + let layer_id = k.layout.b().current_layer(); + let layer_name = &k.layer_info[layer_id].name; + let layer_icon = &k.layer_info[layer_id].icon; + let layer_icon_s = layer_icon.clone().unwrap_or("✗".to_string()); + debug!( + "pre reload tray_icon={} layer_name={} layer_icon={}", + cfg_icon_s, layer_name, layer_icon_s + ); + } + match i { + Some(idx) => { + if let Ok(()) = k.live_reload_n(idx) { + msg_title += &("🔄 \"".to_owned() + cfg_name + "\" loaded"); + flags |= f_tray::USER_ICON; + } else { + msg_title += &("🔄 \"".to_owned() + cfg_name + "\" NOT loaded"); + flags |= f_tray::ERROR_ICON | f_tray::LARGE_ICON; + self.tray.show( + &msg_content, + Some(&msg_title), + Some(flags), + Some(&self.icon), + ); + bail!("{msg_content}"); + } + } + None => { + if let Ok(()) = k.live_reload() { + msg_title += &("🔄 \"".to_owned() + cfg_name + "\" reloaded"); + flags |= f_tray::USER_ICON; + } else { + msg_title += &("🔄 \"".to_owned() + cfg_name + "\" NOT reloaded"); + flags |= f_tray::ERROR_ICON | f_tray::LARGE_ICON; + self.tray.show( + &msg_content, + Some(&msg_title), + Some(flags), + Some(&self.icon), + ); + bail!("{msg_content}"); + } + } + }; + let cfg_icon = &k.tray_icon; + let layer_id = k.layout.b().current_layer(); + let layer_name = &k.layer_info[layer_id].name; + let layer_icon = &k.layer_info[layer_id].icon; + let mut cfg_layer_pkey = PathBuf::new(); // path key + cfg_layer_pkey.push(path_cur_cc.clone()); + cfg_layer_pkey.push(PRE_LAYER.to_owned() + layer_name); //:invalid path marker, + // so should be safe to use as + // a separator + let cfg_layer_pkey_s = cfg_layer_pkey.display().to_string(); + if log_enabled!(Debug) { + let layer_icon_s = layer_icon.clone().unwrap_or("✗".to_string()); + debug!( + "pos reload tray_icon={:?} layer_name={:?} layer_icon={:?}", + cfg_icon, layer_name, layer_icon_s + ); + } + + { + let mut app_data = self.app_data.borrow_mut(); + app_data.cfg_icon.clone_from(cfg_icon); + app_data.layer0_name.clone_from(&k.layer_info[0].name); + app_data.layer0_icon = Some(k.layer_info[0].name.clone()); + app_data.icon_match_layer_name = k.icon_match_layer_name; + self.tray.set_tip(&cfg_layer_pkey_s); // update tooltip to point to the newer config + } + let clear = i.is_none(); + self.update_tray_icon( + cfg_layer_pkey, + &cfg_layer_pkey_s, + layer_name, + layer_icon, + path_cur_cc, + clear, + ) + } else { + msg_title += "✗ Config NOT reloaded, no CFG"; + warn!("{}", msg_title); + flags |= f_tray::ERROR_ICON; + }; + flags |= f_tray::LARGE_ICON; // todo: fails without this, must have SM_CXICON x SM_CYICON? + self.tray.show( + &msg_content, + Some(&msg_title), + Some(flags), + Some(&self.icon), + ); + Ok(()) + } + /// Update tray icon data on layer change + fn reload_layer_icon(&self) { + if let Some(cfg) = CFG.get() { + if let Some(k) = cfg.try_lock() { + let paths = &k.cfg_paths; + let idx_cfg = k.cur_cfg_idx; + let path_cur = &paths[idx_cfg]; + let path_cur_cc = path_cur.clone(); + let cfg_icon = &k.tray_icon; + let layer_id = k.layout.b().current_layer(); + let layer_name = &k.layer_info[layer_id].name; + let layer_icon = &k.layer_info[layer_id].icon; + + let mut cfg_layer_pkey = PathBuf::new(); // path key + cfg_layer_pkey.push(path_cur_cc.clone()); + cfg_layer_pkey.push(PRE_LAYER.to_owned() + layer_name); //:invalid path marker, + // so should be safe + // to use as a separator + let cfg_layer_pkey_s = cfg_layer_pkey.display().to_string(); + if log_enabled!(Debug) { + let cfg_name = &path_cur + .file_name() + .unwrap_or_else(|| OsStr::new("")) + .to_string_lossy() + .to_string(); + let cfg_icon_s = layer_icon.clone().unwrap_or("✗".to_string()); + let layer_icon_s = cfg_icon.clone().unwrap_or("✗".to_string()); + debug!( + "✓ layer changed to ‘{}’ with icon ‘{}’ @ ‘{}’ tray_icon ‘{}’", + layer_name, layer_icon_s, cfg_name, cfg_icon_s + ); + } + + self.tray.set_tip(&cfg_layer_pkey_s); // update tooltip to point to the newer config + let clear = false; + self.update_tray_icon( + cfg_layer_pkey, + &cfg_layer_pkey_s, + layer_name, + layer_icon, + path_cur_cc, + clear, + ) + } else { + debug!("✗ kanata config is locked, can't get current layer (likely the gui changed the layer and is still holding the lock, it will update the icon)"); + } + } else { + warn!("✗ Layer indicator NOT changed, no CFG"); + }; + } + /// Update tray icon data given various config/layer info + fn update_tray_icon( + &self, + cfg_layer_pkey: PathBuf, + cfg_layer_pkey_s: &str, + layer_name: &str, + layer_icon: &Option, + path_cur_cc: PathBuf, + clear: bool, + ) { + let mut icon_dyn = self.icon_dyn.borrow_mut(); // update the tray icon + let mut icon_active = self.icon_active.borrow_mut(); // update the tray icon active path + let mut img_dyn = self.img_dyn.borrow_mut(); // update the tray images + if clear { + *icon_dyn = Default::default(); + *icon_active = Default::default(); + *img_dyn = Default::default(); + debug!("reloading active config, clearing icon_dyn/_active cache"); + } + let app_data = self.app_data.borrow(); + if let Some(icon_opt) = icon_dyn.get(&cfg_layer_pkey) { + // 1a config+layer path has already been checked + if let Some(icon) = icon_opt { + self.tray.set_icon(icon); + *icon_active = Some(cfg_layer_pkey); + } else { + debug!( + "no icon found, using default for config+layer = {}", + cfg_layer_pkey_s + ); + self.tray.set_icon(&self.icon); + *icon_active = Some(cfg_layer_pkey); + } + } else if let Some(layer_icon) = layer_icon { + // 1b cfg+layer path hasn't been checked, but layer has an icon configured, so check it + if let Some(ico_p) = get_icon_p( + layer_icon, + layer_name, + "", + &path_cur_cc, + &app_data.icon_match_layer_name, + ) { + let mut cfg_icon_bitmap = Default::default(); + if let Ok(()) = nwg::Bitmap::builder() + .source_file(Some(&ico_p)) + .strict(false) + .build(&mut cfg_icon_bitmap) + { + debug!( + "✓ Using an icon from this config+layer: {}", + cfg_layer_pkey_s + ); + let temp_icon = cfg_icon_bitmap.copy_as_icon(); + let _ = icon_dyn.insert(cfg_layer_pkey.clone(), Some(temp_icon)); + *icon_active = Some(cfg_layer_pkey); + let temp_icon = cfg_icon_bitmap.copy_as_icon(); + self.tray.set_icon(&temp_icon); + } else { + warn!( + "✗ Invalid icon file \"{layer_icon}\" from this config+layer: {}", + cfg_layer_pkey_s + ); + let _ = icon_dyn.insert(cfg_layer_pkey.clone(), None); + *icon_active = Some(cfg_layer_pkey); + self.tray.set_icon(&self.icon); + } + } else { + warn!( + "✗ Invalid icon path \"{layer_icon}\" from this config+layer: {}", + cfg_layer_pkey_s + ); + let _ = icon_dyn.insert(cfg_layer_pkey.clone(), None); + *icon_active = Some(cfg_layer_pkey); + self.tray.set_icon(&self.icon); + } + } else if icon_dyn.contains_key(&path_cur_cc) { + // 2a no layer icon configured, but config icon exists, use it + if let Some(icon) = icon_dyn.get(&path_cur_cc).unwrap() { + self.tray.set_icon(icon); + *icon_active = Some(path_cur_cc); + } else { + debug!( + "no icon found, using default for config: {}", + path_cur_cc.display().to_string() + ); + self.tray.set_icon(&self.icon); + *icon_active = Some(path_cur_cc); + } + } else { + // 2a no layer icon configured, no config icon, use config path + let cfg_icon_p = if let Some(cfg_icon) = &app_data.cfg_icon { + cfg_icon + } else { + "" + }; + if let Some(ico_p) = get_icon_p( + "", + layer_name, + cfg_icon_p, + &path_cur_cc, + &app_data.icon_match_layer_name, + ) { + let mut cfg_icon_bitmap = Default::default(); + if let Ok(()) = nwg::Bitmap::builder() + .source_file(Some(&ico_p)) + .strict(false) + .build(&mut cfg_icon_bitmap) + { + debug!( + "✓ Using an icon from this config: {}", + path_cur_cc.display().to_string() + ); + let temp_icon = cfg_icon_bitmap.copy_as_icon(); + let _ = icon_dyn.insert(cfg_layer_pkey.clone(), Some(temp_icon)); + *icon_active = Some(cfg_layer_pkey); + let temp_icon = cfg_icon_bitmap.copy_as_icon(); + self.tray.set_icon(&temp_icon); + } else { + warn!( + "✗ Invalid icon file \"{cfg_icon_p}\" from this config: {}", + path_cur_cc.display().to_string() + ); + let _ = icon_dyn.insert(cfg_layer_pkey.clone(), None); + *icon_active = Some(cfg_layer_pkey); + self.tray.set_icon(&self.icon); + } + } else { + warn!( + "✗ Invalid icon path \"{cfg_icon_p}\" from this config: {}", + path_cur_cc.display().to_string() + ); + let _ = icon_dyn.insert(cfg_layer_pkey.clone(), None); + *icon_active = Some(cfg_layer_pkey); + self.tray.set_icon(&self.icon); + } + } + } + fn exit(&self) { + let handlers = self.handlers_dyn.borrow(); + for handler in handlers.iter() { + nwg::unbind_event_handler(handler); + } + nwg::stop_thread_dispatch(); + } +} + +pub mod system_tray_ui { + use super::*; + use core::cmp; + use native_windows_gui::{self as nwg, MousePressEvent}; + use std::cell::RefCell; + use std::ops::Deref; + use std::rc::Rc; + use windows_sys::Win32::UI::Shell::SIID_DELETE; + + pub struct SystemTrayUi { + inner: Rc, + handler_def: RefCell>, + } + + impl nwg::NativeUi for SystemTray { + fn build_ui(mut d: SystemTray) -> Result { + use nwg::Event as E; + + let app_data = d.app_data.borrow().clone(); + d.tray_item_dyn = RefCell::new(Default::default()); + d.handlers_dyn = RefCell::new(Default::default()); + // Resources + d.embed = Default::default(); + d.embed = nwg::EmbedResource::load(Some("kanata.exe"))?; + nwg::Icon::builder() + .source_embed(Some(&d.embed)) + .source_embed_str(Some("iconMain")) + .strict(true) /*use sys, not panic, if missing*/ + .build(&mut d.icon)?; + + // Controls + nwg::MessageWindow::builder().build(&mut d.window)?; + nwg::Notice::builder() + .parent(&d.window) + .build(&mut d.layer_notice)?; + nwg::Menu::builder() + .parent(&d.window) + .popup(true) /*context menu*/ // + .build(&mut d.tray_menu)?; + nwg::Menu::builder() + .parent(&d.tray_menu) + .text("&F Load config") // + .build(&mut d.tray_1cfg_m)?; + nwg::MenuItem::builder() + .parent(&d.tray_menu) + .text("&R Reload config") // + .build(&mut d.tray_2reload)?; + nwg::MenuItem::builder() + .parent(&d.tray_menu) + .text("&X Exit\t‹⎈␠⎋") // + .build(&mut d.tray_3exit)?; + + let mut tmp_bitmap = Default::default(); + nwg::Bitmap::builder() + .source_embed(Some(&d.embed)) + .source_embed_str(Some("imgReload")) + .strict(true) + .size(Some((24, 24))) + .build(&mut tmp_bitmap)?; + let img_exit = nwg::Bitmap::from_system_icon(SIID_DELETE); + d.tray_2reload.set_bitmap(Some(&tmp_bitmap)); + d.tray_3exit.set_bitmap(Some(&img_exit)); + d.img_reload = tmp_bitmap; + d.img_exit = img_exit; + + let mut main_tray_icon_l = Default::default(); + let mut main_tray_icon_is = false; + { + let mut tray_item_dyn = d.tray_item_dyn.borrow_mut(); //extra scope to drop borrowed + let mut icon_dyn = d.icon_dyn.borrow_mut(); + let mut img_dyn = d.img_dyn.borrow_mut(); + let mut icon_active = d.icon_active.borrow_mut(); + const MENU_ACC: &str = "ASDFGQWERTZXCVBYUIOPHJKLNM"; + let layer0_icon_s = &app_data.layer0_icon.clone().unwrap_or("".to_string()); + let cfg_icon_s = &app_data.cfg_icon.clone().unwrap_or("".to_string()); + if !(app_data.cfg_p).is_empty() { + for (i, cfg_p) in app_data.cfg_p.iter().enumerate() { + let i_acc = match i { + // accelerators from 1–0, A–Z starting from home row for easier presses + 0..=8 => format!("&{} ", i + 1), + 9 => format!("&{} ", 0), + 10..=35 => format!( + "&{} ", + &MENU_ACC[(i - 10)..cmp::min(i - 10 + 1, MENU_ACC.len())] + ), + _ => " ".to_string(), + }; + let cfg_name = &cfg_p + .file_name() + .unwrap_or_else(|| OsStr::new("")) + .to_string_lossy() + .to_string(); //kanata.kbd + let menu_text = format!("{cfg_name}\t{i_acc}"); // kanata.kbd &1 + let mut menu_item = Default::default(); + if i == 0 { + nwg::MenuItem::builder() + .parent(&d.tray_1cfg_m) + .text(&menu_text) + .check(true) + .build(&mut menu_item)?; + } else { + nwg::MenuItem::builder() + .parent(&d.tray_1cfg_m) + .text(&menu_text) + .build(&mut menu_item)?; + } + if i == 0 { + // add icons if exists, hashed by config path + // (for active config, others will create on load) + if let Some(ico_p) = get_icon_p( + layer0_icon_s, + &app_data.layer0_name, + cfg_icon_s, + cfg_p, + &app_data.icon_match_layer_name, + ) { + let mut cfg_layer_pkey = PathBuf::new(); // path key + cfg_layer_pkey.push(cfg_p.clone()); + cfg_layer_pkey.push(PRE_LAYER.to_owned() + &app_data.layer0_name); + let cfg_layer_pkey_s = cfg_layer_pkey.display().to_string(); + let mut cfg_icon_bitmap = Default::default(); + if let Ok(()) = nwg::Bitmap::builder() + .source_file(Some(&ico_p)) + .strict(false) + .build(&mut cfg_icon_bitmap) + { + debug!("✓ main 0 config: using icon for {}", cfg_layer_pkey_s); + let temp_icon = cfg_icon_bitmap.copy_as_icon(); + let _ = icon_dyn.insert(cfg_layer_pkey, Some(temp_icon)); + let temp_icon = cfg_icon_bitmap.copy_as_icon(); + main_tray_icon_l = temp_icon; + main_tray_icon_is = true; + } else { + debug!("✗ main 0 icon ✓ icon path, will be using DEFAULT icon for {:?}",cfg_p); + let _ = icon_dyn.insert(cfg_layer_pkey, None); + } + } else { + debug!("✗ main 0 config: using DEFAULT icon for {:?}", cfg_p); + let mut temp_icon = Default::default(); + nwg::Icon::builder() + .source_embed(Some(&d.embed)) + .source_embed_str(Some("iconMain")) + .strict(true) + .build(&mut temp_icon)?; + let _ = icon_dyn.insert(cfg_p.clone(), Some(temp_icon)); + *icon_active = Some(cfg_p.clone()); + } + // Set tray menu config item icons, ignores layers since these + // are per config + if let Some(cfg_icon_bitmap) = + set_menu_item_cfg_icon(&mut menu_item, cfg_icon_s, cfg_p) + { + d.tray_1cfg_m.set_bitmap(Some(&cfg_icon_bitmap)); // show currently + // active config's + // icon in the + // combo menu + let _ = img_dyn.insert(cfg_p.clone(), Some(cfg_icon_bitmap)); + } else { + let _ = img_dyn.insert(cfg_p.clone(), None); + } + } + tray_item_dyn.push(menu_item); + } + } else { + warn!("Didn't get any config paths from Kanata!") + } + } + let main_tray_icon = match main_tray_icon_is { + true => Some(&main_tray_icon_l), + false => Some(&d.icon), + }; + nwg::TrayNotification::builder() + .parent(&d.window) + .icon(main_tray_icon) + .tip(Some(&app_data.tooltip)) + .build(&mut d.tray)?; + + let ui = SystemTrayUi { + // Wrap-up + inner: Rc::new(d), + handler_def: Default::default(), + }; + + let evt_ui = Rc::downgrade(&ui.inner); // Events + let handle_events = move |evt, _evt_data, handle| { + if let Some(evt_ui) = evt_ui.upgrade() { + match evt { + E::OnNotice => + if handle == evt_ui.layer_notice { + SystemTray::reload_layer_icon(&evt_ui);} + E::OnWindowClose => + if handle == evt_ui.window {SystemTray::exit (&evt_ui);} + E::OnMousePress(MousePressEvent::MousePressLeftUp) => + if handle == evt_ui.tray {SystemTray::show_menu(&evt_ui);} + E::OnContextMenu/*🖰›*/ => + if handle == evt_ui.tray {SystemTray::show_menu(&evt_ui);} + E::OnMenuHover => + if handle == evt_ui.tray_1cfg_m { + SystemTray::check_active(&evt_ui);} + E::OnMenuItemSelected => + if handle == evt_ui.tray_2reload { + let _ = SystemTray::reload_cfg(&evt_ui,None); + SystemTray::update_tray_icon_cfg_group(&evt_ui,true); + } else if handle == evt_ui.tray_3exit {SystemTray::exit (&evt_ui); + } else if let + ControlHandle::MenuItem(_parent, _id) = handle { + {let tray_item_dyn = &evt_ui.tray_item_dyn.borrow(); // + for (i, h_cfg) in tray_item_dyn.iter().enumerate() { + if &handle == h_cfg { + for h_cfg_j in tray_item_dyn.iter() { + if h_cfg_j.checked() {h_cfg_j.set_checked(false);} } // uncheck + // others + h_cfg.set_checked(true); // check self + let _ = SystemTray::reload_cfg(&evt_ui,Some(i)); // depends + } + } + } + } + _ => {} + } + } + }; + ui.handler_def + .borrow_mut() + .push(nwg::full_bind_event_handler( + &ui.window.handle, + handle_events, + )); + Ok(ui) + } + } + + impl Drop for SystemTrayUi { + /// To make sure that everything is freed without issues, the default handler + /// must be unbound. + fn drop(&mut self) { + let mut handlers = self.handler_def.borrow_mut(); + for handler in handlers.drain(0..) { + nwg::unbind_event_handler(&handler); + } + } + } + impl Deref for SystemTrayUi { + type Target = SystemTray; + fn deref(&self) -> &Self::Target { + &self.inner + } + } +} + +pub fn build_tray(cfg: &Arc>) -> Result { + let k = cfg.lock(); + let paths = &k.cfg_paths; + let cfg_icon = &k.tray_icon; + let path_cur = &paths[0]; + let layer0_id = k.layout.b().current_layer(); + let layer0_name = &k.layer_info[layer0_id].name; + let layer0_icon = &k.layer_info[layer0_id].icon; + let icon_match_layer_name = &k.icon_match_layer_name; + let app_data = SystemTrayData { + tooltip: path_cur.display().to_string(), + cfg_p: paths.clone(), + cfg_icon: cfg_icon.clone(), + layer0_name: layer0_name.clone(), + layer0_icon: layer0_icon.clone(), + icon_match_layer_name: *icon_match_layer_name, + }; + let app = SystemTray { + app_data: RefCell::new(app_data), + ..Default::default() + }; + SystemTray::build_ui(app).context("Failed to build UI") +} + +pub use log::*; +pub use std::io::{stdout, IsTerminal}; +pub use winapi::shared::minwindef::BOOL; +pub use winapi::um::wincon::{AttachConsole, FreeConsole, ATTACH_PARENT_PROCESS}; + +use once_cell::sync::Lazy; +pub static IS_TERM: Lazy = Lazy::new(|| stdout().is_terminal()); +pub static IS_CONSOLE: Lazy = + Lazy::new(|| unsafe { AttachConsole(ATTACH_PARENT_PROCESS) != 0i32 }); diff --git a/src/gui/win_nwg_ext/license-MIT b/src/gui/win_nwg_ext/license-MIT new file mode 100644 index 000000000..a9d02d7ff --- /dev/null +++ b/src/gui/win_nwg_ext/license-MIT @@ -0,0 +1,26 @@ +The MIT License (MIT) +===================== + +Copyright © `2024` `Niccolò Betto` + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + diff --git a/src/gui/win_nwg_ext/mod.rs b/src/gui/win_nwg_ext/mod.rs new file mode 100644 index 000000000..8066bf492 --- /dev/null +++ b/src/gui/win_nwg_ext/mod.rs @@ -0,0 +1,169 @@ +// based on https://github.com/lynxnb/wsl-usb-manager/blob/master/src/gui/nwg_ext.rs +use native_windows_gui as nwg; + +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Graphics::Gdi::DeleteObject; +use windows_sys::Win32::UI::Shell::{ + SHGetStockIconInfo, SHGSI_ICON, SHGSI_SMALLICON, SHSTOCKICONID, SHSTOCKICONINFO, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CopyImage, DestroyIcon, GetIconInfoExW, SetMenuItemInfoW, HMENU, ICONINFOEXW, IMAGE_BITMAP, + LR_CREATEDIBSECTION, MENUITEMINFOW, MF_BYCOMMAND, MIIM_BITMAP, +}; + +/// Extends [`nwg::Bitmap`] with additional functionality. +pub trait BitmapEx { + fn from_system_icon(icon: SHSTOCKICONID) -> nwg::Bitmap; +} + +impl BitmapEx for nwg::Bitmap { + /// Creates a bitmap from a [`SHSTOCKICONID`] system icon ID. + fn from_system_icon(icon: SHSTOCKICONID) -> nwg::Bitmap { + // Retrieve the icon + let mut stock_icon_info = SHSTOCKICONINFO { + cbSize: std::mem::size_of::() as u32, + hIcon: 0, + iSysImageIndex: 0, + iIcon: 0, + szPath: [0; 260], + }; + unsafe { + SHGetStockIconInfo( + icon, + SHGSI_ICON | SHGSI_SMALLICON, + &mut stock_icon_info as *mut _, + ); + } + + // Retrieve the bitmap for the icon + let mut icon_info = ICONINFOEXW { + cbSize: std::mem::size_of::() as u32, + fIcon: 0, + xHotspot: 0, + yHotspot: 0, + hbmMask: 0, + hbmColor: 0, + wResID: 0, + szModName: [0; 260], + szResName: [0; 260], + }; + unsafe { + GetIconInfoExW(stock_icon_info.hIcon, &mut icon_info as *mut _); + } + + // Create a copy of the bitmap with transparent background from the icon bitmap + let hbitmap = unsafe { + CopyImage( + icon_info.hbmColor as HANDLE, + IMAGE_BITMAP, + 0, + 0, + LR_CREATEDIBSECTION, + ) + }; + + // Delete the unused icon and bitmaps + unsafe { + DeleteObject(icon_info.hbmMask); + DeleteObject(icon_info.hbmColor); + DestroyIcon(stock_icon_info.hIcon); + }; + + if hbitmap == 0 { + panic!("Failed to create bitmap from system icon"); + } else { + #[allow(unused)] + struct Bitmap { + handle: HANDLE, + owned: bool, + } + + let bitmap = Bitmap { + handle: hbitmap as HANDLE, + owned: true, + }; + + // Ugly hack to set the private `owned` field inside nwg::Bitmap to true + unsafe { std::mem::transmute(bitmap) } + } + } +} + +/// Extends [`nwg::Menu`] with additional functionality. +pub trait MenuEx { + fn set_bitmap(&self, bitmap: Option<&nwg::Bitmap>); +} +impl MenuEx for nwg::Menu { + /// Sets a bitmap to be displayed on a menu. Pass `None` to remove the bitmap + fn set_bitmap(&self, bitmap: Option<&nwg::Bitmap>) { + use std::{mem::size_of, ptr}; + let (hmenu_par, hmenu) = self.handle.hmenu().unwrap(); + let hbitmap = match bitmap { + Some(b) => b.handle as HANDLE, + None => 0, + }; + + let menu_item_info = MENUITEMINFOW { + cbSize: size_of::() as u32, + fMask: MIIM_BITMAP, + hbmpItem: hbitmap, + fType: 0, + fState: 0, + hSubMenu: 0, + hbmpChecked: 0, + hbmpUnchecked: 0, + dwTypeData: ptr::null_mut(), + wID: 0, + dwItemData: 0, + cch: 0, + }; + unsafe { + SetMenuItemInfoW( + hmenu_par as HMENU, + hmenu as u32, + MF_BYCOMMAND as i32, + &menu_item_info as *const _, + ); + } + } +} + +/// Extends [`nwg::MenuItem`] with additional functionality. +pub trait MenuItemEx { + fn set_bitmap(&self, bitmap: Option<&nwg::Bitmap>); +} + +impl MenuItemEx for nwg::MenuItem { + /// Sets a bitmap to be displayed on a menu item. Pass `None` to remove the bitmap. + fn set_bitmap(&self, bitmap: Option<&nwg::Bitmap>) { + let (hmenu, item_id) = self.handle.hmenu_item().unwrap(); + let hbitmap = match bitmap { + Some(b) => b.handle as HANDLE, + None => 0, + }; + + let menu_item_info = MENUITEMINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: MIIM_BITMAP, + fType: 0, + fState: 0, + wID: 0, + hSubMenu: 0, + hbmpChecked: 0, + hbmpUnchecked: 0, + dwItemData: 0, + dwTypeData: std::ptr::null_mut(), + cch: 0, + hbmpItem: hbitmap, + }; + + unsafe { + SetMenuItemInfoW( + hmenu as HMENU, + item_id, + MF_BYCOMMAND as i32, + &menu_item_info as *const _, + ); + } + } +} diff --git a/src/kanata.exe.manifest.rc b/src/kanata.exe.manifest.rc index 9c6384d3d..a37373344 100644 --- a/src/kanata.exe.manifest.rc +++ b/src/kanata.exe.manifest.rc @@ -1,2 +1,4 @@ #define RT_MANIFEST 24 1 RT_MANIFEST "./target/kanata.exe.manifest" +iconMain ICON "../assets/kanata.ico" +imgReload IMAGE "../assets/reload_32px.png" diff --git a/src/kanata/mod.rs b/src/kanata/mod.rs index bba8d8e41..81d50f781 100755 --- a/src/kanata/mod.rs +++ b/src/kanata/mod.rs @@ -1,5 +1,7 @@ //! Implements the glue between OS input/output and keyberon state management. +#[cfg(all(target_os = "windows", feature = "gui"))] +use crate::gui::win::*; use anyhow::{bail, Result}; use kanata_parser::sequences::*; use log::{error, info}; @@ -197,6 +199,12 @@ pub struct Kanata { pub switch_max_key_timing: u16, #[cfg(feature = "tcp_server")] tcp_server_address: Option, + #[cfg(all(target_os = "windows", feature = "gui"))] + /// File name / path to the tray icon file. + pub tray_icon: Option, + #[cfg(all(target_os = "windows", feature = "gui"))] + /// Whether to match layer names to icon files without an explicit 'icon' field + pub icon_match_layer_name: bool, } #[derive(PartialEq, Clone, Copy)] @@ -359,6 +367,10 @@ impl Kanata { switch_max_key_timing: cfg.switch_max_key_timing, #[cfg(feature = "tcp_server")] tcp_server_address: args.tcp_server_address.clone(), + #[cfg(all(target_os = "windows", feature = "gui"))] + tray_icon: cfg.options.tray_icon, + #[cfg(all(target_os = "windows", feature = "gui"))] + icon_match_layer_name: cfg.options.icon_match_layer_name, }) } @@ -452,6 +464,10 @@ impl Kanata { switch_max_key_timing: cfg.switch_max_key_timing, #[cfg(feature = "tcp_server")] tcp_server_address: None, + #[cfg(all(target_os = "windows", feature = "gui"))] + tray_icon: None, + #[cfg(all(target_os = "windows", feature = "gui"))] + icon_match_layer_name: cfg.options.icon_match_layer_name, }) } @@ -487,6 +503,11 @@ impl Kanata { self.virtual_keys = cfg.fake_keys; } self.switch_max_key_timing = cfg.switch_max_key_timing; + #[cfg(all(target_os = "windows", feature = "gui"))] + { + self.tray_icon = cfg.options.tray_icon; + self.icon_match_layer_name = cfg.options.icon_match_layer_name; + } *MAPPED_KEYS.lock() = cfg.mapped_keys; #[cfg(target_os = "linux")] @@ -529,6 +550,8 @@ impl Kanata { } } } + #[cfg(all(target_os = "windows", feature = "gui"))] + send_gui_notice(); Ok(()) } @@ -1505,6 +1528,8 @@ impl Kanata { } } } + #[cfg(all(target_os = "windows", feature = "gui"))] + send_gui_notice(); } } @@ -1935,14 +1960,21 @@ fn check_for_exit(event: &KeyEvent) { } const EXIT_MSG: &str = "pressed LControl+Space+Escape, exiting"; if IS_ESC_PRESSED.load(SeqCst) && IS_SPC_PRESSED.load(SeqCst) && IS_LCL_PRESSED.load(SeqCst) { - #[cfg(not(target_os = "linux"))] + log::info!("{EXIT_MSG}"); + #[cfg(all(target_os = "windows", feature = "gui"))] + { + native_windows_gui::stop_thread_dispatch(); + } + #[cfg(all( + not(target_os = "linux"), + not(target_os = "windows"), + not(feature = "gui") + ))] { - log::info!("{EXIT_MSG}"); panic!("{EXIT_MSG}"); } #[cfg(target_os = "linux")] { - log::info!("{EXIT_MSG}"); signal_hook::low_level::raise(signal_hook::consts::SIGTERM).expect("raise signal"); } } diff --git a/src/kanata/windows/llhook.rs b/src/kanata/windows/llhook.rs index e501fc49e..b29aeb46f 100644 --- a/src/kanata/windows/llhook.rs +++ b/src/kanata/windows/llhook.rs @@ -10,15 +10,20 @@ use crate::kanata::*; impl Kanata { /// Initialize the callback that is passed to the Windows low level hook to receive key events /// and run the native_windows_gui event loop. - pub fn event_loop(_kanata: Arc>, tx: Sender) -> Result<()> { + pub fn event_loop( + _cfg: Arc>, + tx: Sender, + #[cfg(all(target_os = "windows", feature = "gui"))] + ui: crate::gui::system_tray_ui::SystemTrayUi, + ) -> Result<()> { // Display debug and panic output when launched from a terminal. + #[cfg(not(feature = "gui"))] unsafe { use winapi::um::wincon::*; if AttachConsole(ATTACH_PARENT_PROCESS) != 0 { panic!("Could not attach to console"); } }; - native_windows_gui::init()?; let (preprocess_tx, preprocess_rx) = sync_channel(100); start_event_preprocessor(preprocess_rx, tx); @@ -66,7 +71,9 @@ impl Kanata { true }); - // The event loop is also required for the low-level keyboard hook to work. + #[cfg(all(target_os = "windows", feature = "gui"))] + let _ui = ui; // prevents thread from panicking on exiting via a GUI + // The event loop is also required for the low-level keyboard hook to work. native_windows_gui::dispatch_thread_events(); Ok(()) } diff --git a/src/kanata/windows/mod.rs b/src/kanata/windows/mod.rs index 513999d93..8b42186b4 100644 --- a/src/kanata/windows/mod.rs +++ b/src/kanata/windows/mod.rs @@ -127,4 +127,32 @@ impl Kanata { pub fn check_release_non_physical_shift(&mut self) -> Result<()> { Ok(()) } + + #[cfg(feature = "gui")] + pub fn live_reload(&mut self) -> Result<()> { + self.live_reload_requested = true; + self.do_live_reload(&None)?; + Ok(()) + } + #[cfg(feature = "gui")] + pub fn live_reload_n(&mut self, n: usize) -> Result<()> { + // can't use in CustomAction::LiveReloadNum(n) due to 2nd mut borrow + self.live_reload_requested = true; + // let backup_cfg_idx = self.cur_cfg_idx; + match self.cfg_paths.get(n) { + Some(path) => { + self.cur_cfg_idx = n; + log::info!("Requested live reload of file: {}", path.display(),); + } + None => { + log::error!("Requested live reload of config file number {}, but only {} config files were passed", n+1, self.cfg_paths.len()); + } + } + // if let Err(e) = self.do_live_reload(&None) { + // self.cur_cfg_idx = backup_cfg_idx; // restore index on fail when. TODO: add when a similar reversion is added to other custom actions + // return Err(e) + // } + self.do_live_reload(&None)?; + Ok(()) + } } diff --git a/src/lib.rs b/src/lib.rs index 075d8d921..5473d6436 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,13 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::str::FromStr; +#[cfg(all(target_os = "windows", feature = "gui"))] +pub mod gui; pub mod kanata; pub mod oskbd; pub mod tcp_server; +#[cfg(test)] +pub mod tests; pub use kanata::*; pub use tcp_server::TcpServer; diff --git a/src/main.rs b/src/main.rs index cac3a156e..7afe03bd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,22 @@ +mod main_lib; + use anyhow::{bail, Result}; use clap::Parser; use kanata_parser::cfg; use kanata_state_machine::*; -use log::info; use simplelog::{format_description, *}; use std::path::PathBuf; -#[cfg(test)] -mod tests; - #[derive(Parser, Debug)] #[command(author, version, verbatim_doc_comment)] /// kanata: an advanced software key remapper /// /// kanata remaps key presses to other keys or complex actions depending on the /// configuration for that key. You can find the guide for creating a config -/// file here: -/// -/// https://github.com/jtroo/kanata/blob/main/docs/config.adoc +/// file here: https://github.com/jtroo/kanata/blob/main/docs/config.adoc /// /// If you need help, please feel welcome to create an issue or discussion in -/// the kanata repository: -/// -/// https://github.com/jtroo/kanata +/// the kanata repository: https://github.com/jtroo/kanata struct Args { // Display different platform specific paths based on the target OS #[cfg_attr( @@ -46,8 +40,8 @@ kanata.kbd in the current working directory and #[arg(short, long, verbatim_doc_comment)] cfg: Option>, - /// Port or full address (IP:PORT) to run the optional TCP server on. If blank, no TCP port will be - /// listened on. + /// Port or full address (IP:PORT) to run the optional TCP server on. If blank, + /// no TCP port will be listened on. #[cfg(feature = "tcp_server")] #[arg( short = 'p', @@ -94,136 +88,146 @@ kanata.kbd in the current working directory and check: bool, } -/// Parse CLI arguments and initialize logging. -fn cli_init() -> Result { - let args = Args::parse(); +#[cfg(not(feature = "gui"))] +mod cli { + use super::*; - #[cfg(target_os = "macos")] - if args.list { - karabiner_driverkit::list_keyboards(); - std::process::exit(0); - } + /// Parse CLI arguments and initialize logging. + fn cli_init() -> Result { + let args = Args::parse(); + + #[cfg(target_os = "macos")] + if args.list { + karabiner_driverkit::list_keyboards(); + std::process::exit(0); + } - let cfg_paths = args.cfg.unwrap_or_else(default_cfg); - - let log_lvl = match (args.debug, args.trace) { - (_, true) => LevelFilter::Trace, - (true, false) => LevelFilter::Debug, - (false, false) => LevelFilter::Info, - }; - - let mut log_cfg = ConfigBuilder::new(); - if let Err(e) = log_cfg.set_time_offset_to_local() { - eprintln!("WARNING: could not set log TZ to local: {e:?}"); - }; - log_cfg.set_time_format_custom(format_description!( - version = 2, - "[hour]:[minute]:[second].[subsecond digits:4]" - )); - CombinedLogger::init(vec![TermLogger::new( - log_lvl, - log_cfg.build(), - TerminalMode::Mixed, - ColorChoice::AlwaysAnsi, - )]) - .expect("logger can init"); - log::info!("kanata v{} starting", env!("CARGO_PKG_VERSION")); - #[cfg(all(not(feature = "interception_driver"), target_os = "windows"))] - log::info!("using LLHOOK+SendInput for keyboard IO"); - #[cfg(all(feature = "interception_driver", target_os = "windows"))] - log::info!("using the Interception driver for keyboard IO"); - - if let Some(config_file) = cfg_paths.first() { - if !config_file.exists() { - bail!( + let cfg_paths = args.cfg.unwrap_or_else(default_cfg); + + let log_lvl = match (args.debug, args.trace) { + (_, true) => LevelFilter::Trace, + (true, false) => LevelFilter::Debug, + (false, false) => LevelFilter::Info, + }; + + let mut log_cfg = ConfigBuilder::new(); + if let Err(e) = log_cfg.set_time_offset_to_local() { + eprintln!("WARNING: could not set log TZ to local: {e:?}"); + }; + log_cfg.set_time_format_custom(format_description!( + version = 2, + "[hour]:[minute]:[second].[subsecond digits:4]" + )); + CombinedLogger::init(vec![TermLogger::new( + log_lvl, + log_cfg.build(), + TerminalMode::Mixed, + ColorChoice::AlwaysAnsi, + )]) + .expect("logger can init"); + + log::info!("kanata v{} starting", env!("CARGO_PKG_VERSION")); + #[cfg(all(not(feature = "interception_driver"), target_os = "windows"))] + log::info!("using LLHOOK+SendInput for keyboard IO"); + #[cfg(all(feature = "interception_driver", target_os = "windows"))] + log::info!("using the Interception driver for keyboard IO"); + + if let Some(config_file) = cfg_paths.first() { + if !config_file.exists() { + bail!( "Could not find the config file ({})\nFor more info, pass the `-h` or `--help` flags.", cfg_paths[0].to_str().unwrap_or("?") ) + } + } else { + bail!("No config files provided\nFor more info, pass the `-h` or `--help` flags."); } - } else { - bail!("No config files provided\nFor more info, pass the `-h` or `--help` flags."); - } - if args.check { - log::info!("validating config only and exiting"); - let status = match cfg::new_from_file(&cfg_paths[0]) { - Ok(_) => 0, - Err(e) => { - log::error!("{e:?}"); - 1 - } - }; - std::process::exit(status); - } + if args.check { + log::info!("validating config only and exiting"); + let status = match cfg::new_from_file(&cfg_paths[0]) { + Ok(_) => 0, + Err(e) => { + log::error!("{e:?}"); + 1 + } + }; + std::process::exit(status); + } - #[cfg(target_os = "linux")] - if let Some(wait) = args.wait_device_ms { - use std::sync::atomic::Ordering; - log::info!("Setting device registration wait time to {wait} ms."); - oskbd::WAIT_DEVICE_MS.store(wait, Ordering::SeqCst); + #[cfg(target_os = "linux")] + if let Some(wait) = args.wait_device_ms { + use std::sync::atomic::Ordering; + log::info!("Setting device registration wait time to {wait} ms."); + oskbd::WAIT_DEVICE_MS.store(wait, Ordering::SeqCst); + } + + Ok(ValidatedArgs { + paths: cfg_paths, + #[cfg(feature = "tcp_server")] + tcp_server_address: args.tcp_server_address, + #[cfg(target_os = "linux")] + symlink_path: args.symlink_path, + nodelay: args.nodelay, + }) } - Ok(ValidatedArgs { - paths: cfg_paths, - #[cfg(feature = "tcp_server")] - tcp_server_address: args.tcp_server_address, - #[cfg(target_os = "linux")] - symlink_path: args.symlink_path, - nodelay: args.nodelay, - }) -} + pub(crate) fn main_impl() -> Result<()> { + let args = cli_init()?; + let kanata_arc = Kanata::new_arc(&args)?; -fn main_impl() -> Result<()> { - let args = cli_init()?; - let kanata_arc = Kanata::new_arc(&args)?; + if !args.nodelay { + log::info!("Sleeping for 2s. Please release all keys and don't press additional ones. Run kanata with --help to see how understand more and how to disable this sleep."); + std::thread::sleep(std::time::Duration::from_secs(2)); + } - if !args.nodelay { - info!("Sleeping for 2s. Please release all keys and don't press additional ones. Run kanata with --help to see how understand more and how to disable this sleep."); - std::thread::sleep(std::time::Duration::from_secs(2)); - } + // Start a processing loop in another thread and run the event loop in this thread. + // + // The reason for two different event loops is that the "event loop" only listens for + // keyboard events, which it sends to the "processing loop". The processing loop handles + // keyboard events while also maintaining `tick()` calls to keyberon. - // Start a processing loop in another thread and run the event loop in this thread. - // - // The reason for two different event loops is that the "event loop" only listens for keyboard - // events, which it sends to the "processing loop". The processing loop handles keyboard events - // while also maintaining `tick()` calls to keyberon. + let (tx, rx) = std::sync::mpsc::sync_channel(100); + + let (server, ntx, nrx) = if let Some(address) = { + #[cfg(feature = "tcp_server")] + { + args.tcp_server_address + } + #[cfg(not(feature = "tcp_server"))] + { + None:: + } + } { + let mut server = TcpServer::new(address.into_inner(), tx.clone()); + server.start(kanata_arc.clone()); + let (ntx, nrx) = std::sync::mpsc::sync_channel(100); + (Some(server), Some(ntx), Some(nrx)) + } else { + (None, None, None) + }; - let (tx, rx) = std::sync::mpsc::sync_channel(100); + Kanata::start_processing_loop(kanata_arc.clone(), rx, ntx, args.nodelay); - let (server, ntx, nrx) = if let Some(address) = { - #[cfg(feature = "tcp_server")] - { - args.tcp_server_address - } - #[cfg(not(feature = "tcp_server"))] - { - None:: + if let (Some(server), Some(nrx)) = (server, nrx) { + #[allow(clippy::unit_arg)] + Kanata::start_notification_loop(nrx, server.connections); } - } { - let mut server = TcpServer::new(address.into_inner(), tx.clone()); - server.start(kanata_arc.clone()); - let (ntx, nrx) = std::sync::mpsc::sync_channel(100); - (Some(server), Some(ntx), Some(nrx)) - } else { - (None, None, None) - }; - - Kanata::start_processing_loop(kanata_arc.clone(), rx, ntx, args.nodelay); - - if let (Some(server), Some(nrx)) = (server, nrx) { - #[allow(clippy::unit_arg)] - Kanata::start_notification_loop(nrx, server.connections); - } - #[cfg(target_os = "linux")] - sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?; + #[cfg(target_os = "linux")] + sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?; - Kanata::event_loop(kanata_arc, tx)?; + #[cfg(any(not(target_os = "windows"), not(feature = "gui")))] + Kanata::event_loop(kanata_arc, tx)?; - Ok(()) + Ok(()) + } } -fn main() -> Result<()> { +#[cfg(not(feature = "gui"))] +use cli::*; +#[cfg(not(feature = "gui"))] +pub fn main() -> Result<()> { let ret = main_impl(); if let Err(ref e) = ret { log::error!("{e}\n"); @@ -232,3 +236,9 @@ fn main() -> Result<()> { let _ = std::io::stdin().read_line(&mut String::new()); ret } + +#[cfg(feature = "gui")] +fn main() { + use main_lib::win_gui::*; + lib_main_gui(); +} diff --git a/src/main_lib/mod.rs b/src/main_lib/mod.rs new file mode 100644 index 000000000..bc545bf97 --- /dev/null +++ b/src/main_lib/mod.rs @@ -0,0 +1,2 @@ +#[cfg(all(target_os = "windows", feature = "gui"))] +pub(crate) mod win_gui; diff --git a/src/main_lib/win_gui.rs b/src/main_lib/win_gui.rs new file mode 100644 index 000000000..bad866df4 --- /dev/null +++ b/src/main_lib/win_gui.rs @@ -0,0 +1,176 @@ +use crate::*; +use anyhow::{anyhow, Context}; +use clap::{error::ErrorKind, CommandFactory}; +use kanata_state_machine::gui::*; +use kanata_state_machine::*; + +/// Parse CLI arguments and initialize logging. +fn cli_init() -> Result { + let args = match Args::try_parse() { + Ok(args) => args, + Err(e) => { + if *IS_TERM { + // init loggers without config so '-help' "error" or real ones can be printed + let mut log_cfg = ConfigBuilder::new(); + CombinedLogger::init(vec![ + TermLogger::new( + LevelFilter::Debug, + log_cfg.build(), + TerminalMode::Mixed, + ColorChoice::AlwaysAnsi, + ), + log_win::windbg_simple_combo(LevelFilter::Debug), + ]) + .expect("logger can init"); + } else { + log_win::init(); + log::set_max_level(LevelFilter::Debug); + } // doesn't panic + match e.kind() { + ErrorKind::DisplayHelp => { + let mut cmd = Args::command(); + let help = cmd.render_help(); + info!("{help}"); + log::set_max_level(LevelFilter::Off); + return Err(anyhow!("")); + } + _ => return Err(e.into()), + } + } + }; + + let cfg_paths = args.cfg.unwrap_or_else(default_cfg); + + let log_lvl = match (args.debug, args.trace) { + (_, true) => LevelFilter::Trace, + (true, false) => LevelFilter::Debug, + (false, false) => LevelFilter::Info, + }; + + let mut log_cfg = ConfigBuilder::new(); + if let Err(e) = log_cfg.set_time_offset_to_local() { + eprintln!("WARNING: could not set log TZ to local: {e:?}"); + }; + log_cfg.set_time_format_custom(format_description!( + version = 2, + "[hour]:[minute]:[second].[subsecond digits:4]" + )); + if *IS_TERM { + CombinedLogger::init(vec![ + TermLogger::new( + log_lvl, + log_cfg.build(), + TerminalMode::Mixed, + ColorChoice::AlwaysAnsi, + ), + log_win::windbg_simple_combo(log_lvl), + ]) + .expect("logger can init"); + } else { + CombinedLogger::init(vec![log_win::windbg_simple_combo(log_lvl)]).expect("logger can init"); + } + log::info!("kanata v{} starting", env!("CARGO_PKG_VERSION")); + #[cfg(all(not(feature = "interception_driver"), target_os = "windows"))] + log::info!("using LLHOOK+SendInput for keyboard IO"); + #[cfg(all(feature = "interception_driver", target_os = "windows"))] + log::info!("using the Interception driver for keyboard IO"); + + if let Some(config_file) = cfg_paths.first() { + if !config_file.exists() { + bail!( + "Could not find the config file ({})\nFor more info, pass the `-h` or `--help` flags.", + cfg_paths[0].to_str().unwrap_or("?") + ) + } + } else { + bail!("No config files provided\nFor more info, pass the `-h` or `--help` flags."); + } + + if args.check { + log::info!("validating config only and exiting"); + let status = match cfg::new_from_file(&cfg_paths[0]) { + Ok(_) => 0, + Err(e) => { + log::error!("{e:?}"); + 1 + } + }; + std::process::exit(status); + } + + Ok(ValidatedArgs { + paths: cfg_paths, + #[cfg(feature = "tcp_server")] + tcp_server_address: args.tcp_server_address, + nodelay: args.nodelay, + }) +} + +fn main_impl() -> Result<()> { + let args = cli_init()?; + let kanata_arc = Kanata::new_arc(&args)?; + + if CFG.set(kanata_arc.clone()).is_err() { + warn!("Someone else set our ‘CFG’"); + }; // store a clone of cfg so that we can ask it to reset itself + + if !args.nodelay { + info!("Sleeping for 2s. Please release all keys and don't press additional ones. Run kanata with --help to see how understand more and how to disable this sleep."); + std::thread::sleep(std::time::Duration::from_secs(2)); + } + + // Start a processing loop in another thread and run the event loop in this thread. + // + // The reason for two different event loops is that the "event loop" only listens for keyboard + // events, which it sends to the "processing loop". The processing loop handles keyboard events + // while also maintaining `tick()` calls to keyberon. + + let (tx, rx) = std::sync::mpsc::sync_channel(100); + + let (server, ntx, nrx) = if let Some(address) = { + #[cfg(feature = "tcp_server")] + { + args.tcp_server_address + } + #[cfg(not(feature = "tcp_server"))] + { + None:: + } + } { + let mut server = TcpServer::new(address.into_inner(), tx.clone()); + server.start(kanata_arc.clone()); + let (ntx, nrx) = std::sync::mpsc::sync_channel(100); + (Some(server), Some(ntx), Some(nrx)) + } else { + (None, None, None) + }; + + native_windows_gui::init().context("Failed to init Native Windows GUI")?; + let ui = build_tray(&kanata_arc)?; + let gui_tx = ui.layer_notice.sender(); + if GUI_TX.set(gui_tx).is_err() { + warn!("Someone else set our ‘GUI_TX’"); + }; + Kanata::start_processing_loop(kanata_arc.clone(), rx, ntx, args.nodelay); + + if let (Some(server), Some(nrx)) = (server, nrx) { + #[allow(clippy::unit_arg)] + Kanata::start_notification_loop(nrx, server.connections); + } + + Kanata::event_loop(kanata_arc, tx, ui)?; + + Ok(()) +} + +pub fn lib_main_gui() { + let _attach_console = *IS_CONSOLE; + let ret = main_impl(); + if let Err(ref e) = ret { + log::error!("{e}\n"); + } + + unsafe { + FreeConsole(); + } +} diff --git a/src/oskbd/simulated.rs b/src/oskbd/simulated.rs index 4913056f1..5e5cce3f5 100644 --- a/src/oskbd/simulated.rs +++ b/src/oskbd/simulated.rs @@ -96,7 +96,8 @@ impl LogFmt { let mut pad = value.len(); let mut time = "".to_string(); if self.ticks > 0 { - pad = std::cmp::max(value.len(), self.ticks.to_string().len()); // add extra padding if event tick is wider + pad = std::cmp::max(value.len(), self.ticks.to_string().len()); // add extra padding if + // event tick is wider time = format!(" {: >(), }; @@ -176,14 +177,17 @@ impl TcpServer { match kanata.lock().kbd_out.set_mouse(x, y) { Ok(_) => { log::info!("sucessfully did set mouse position to: x {x} y {y}"); - // Optionally send a success message to the client + // Optionally send a success message to the + // client } Err(e) => { log::error!( "Failed to set mouse position: {}", e ); - // Implement any error handling logic here, such as sending an error response to the client + // Implement any error handling logic here, + // such as sending an error response to + // the client } } } diff --git a/src/tests/sim_tests/mod.rs b/src/tests/sim_tests/mod.rs index 5d013a55c..12f55a308 100644 --- a/src/tests/sim_tests/mod.rs +++ b/src/tests/sim_tests/mod.rs @@ -5,7 +5,7 @@ //! and see if the real output looks sensible according to what is expected. use crate::tests::*; -use kanata_state_machine::{ +use crate::{ oskbd::{KeyEvent, KeyValue}, str_to_oscode, Kanata, }; diff --git a/win_dbg_logger/Cargo.toml b/win_dbg_logger/Cargo.toml new file mode 100644 index 000000000..8e9533653 --- /dev/null +++ b/win_dbg_logger/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "win_dbg_logger" +version = "0.1.0" +authors = ["Arlie Davis "] +edition = "2018" +license = "MIT OR Apache-2.0" +repository = "https://github.com/sivadeilra/win_dbg_logger" +description = "A logger for use with Windows debuggers." + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +log = "0.4.*" +winapi = {version="0.3.9", features=["processthreadsapi",]} +regex = {version="1.10.4"} +simplelog = {version="0.12.0", optional=true} + +[features] +simple_shared = ["simplelog"] diff --git a/win_dbg_logger/src/lib.rs b/win_dbg_logger/src/lib.rs new file mode 100644 index 000000000..d70e53591 --- /dev/null +++ b/win_dbg_logger/src/lib.rs @@ -0,0 +1,289 @@ +#![allow(non_upper_case_globals)] +//! A logger for use with Windows debuggers. +//! +//! This crate integrates with the ubiquitous [`log`] crate and can be used with the [`simplelog`] crate. +//! +//! Windows allows applications to output a string directly to debuggers. This is very useful in +//! situations where other forms of logging are not available. +//! For example, stderr is not available for GUI apps. +//! +//! Windows provides the `OutputDebugString` entry point, which allows apps to print a debug string. +//! Internally, `OutputDebugString` is implemented by raising an SEH exception, which the debugger +//! catches and handles. +//! +//! Raising an exception has a significant cost, when run under a debugger, because the debugger +//! halts all threads in the target process. So you should avoid using this logger for high rates +//! of output, because doing so will slow down your app. +//! +//! Like many Windows entry points, `OutputDebugString` is actually two entry points: +//! `OutputDebugStringA` (multi-byte encodings) and +//! `OutputDebugStringW` (UTF-16). In most cases, the `*A` version is implemented using a "thunk" +//! which converts its arguments to UTF-16 and then calls the `*W` version. However, +//! `OutputDebugStringA` is one of the few entry points where the opposite is true. +//! +//! This crate can be compiled and used on non-Windows platforms, but it does nothing. +//! This is intended to minimize the impact on code that takes a dependency on this crate. +//! +//! # Example +//! +//! ```rust +//! use log::{debug, info}; +//! +//! fn do_cool_stuff() { +//! info!("Hello, world!"); +//! debug!("Hello, world, in detail!"); +//! } +//! +//! fn main() { +//! log::set_logger(&win_dbg_logger::WINDBG_LOGGER).unwrap(); +//! log::set_max_level(log::LevelFilter::Debug); +//! +//! do_cool_stuff(); +//! } +//! ``` + +use log::{Level, LevelFilter, Metadata, Record}; + +/// This implements `log::Log`, and so can be used as a logging provider. +/// It forwards log messages to the Windows `OutputDebugString` API. +#[derive(Copy, Clone)] +pub struct WinDbgLogger { + level: LevelFilter, + /// Allow for `WinDbgLogger` to possibly have more fields in the future + _priv: (), +} + +/// This is a static instance of `WinDbgLogger`. Since `WinDbgLogger` contains no state, +/// this can be directly registered using `log::set_logger`, e.g.: +/// +/// ``` +/// log::set_logger(&win_dbg_logger::WINDBG_LOGGER).unwrap(); // Initialize +/// log::set_max_level(log::LevelFilter::Debug); +/// +/// use log::{info, debug}; // Import +/// +/// info!("Hello, world!"); debug!("Hello, world, in detail!"); // Use to log +/// ``` +pub static WINDBG_LOGGER: WinDbgLogger = WinDbgLogger { + level: LevelFilter::Trace, + _priv: (), +}; +pub static WINDBG_L1: WinDbgLogger = WinDbgLogger { + level: LevelFilter::Error, + _priv: (), +}; +pub static WINDBG_L2: WinDbgLogger = WinDbgLogger { + level: LevelFilter::Warn, + _priv: (), +}; +pub static WINDBG_L3: WinDbgLogger = WinDbgLogger { + level: LevelFilter::Info, + _priv: (), +}; +pub static WINDBG_L4: WinDbgLogger = WinDbgLogger { + level: LevelFilter::Debug, + _priv: (), +}; +pub static WINDBG_L5: WinDbgLogger = WinDbgLogger { + level: LevelFilter::Trace, + _priv: (), +}; +pub static WINDBG_L0: WinDbgLogger = WinDbgLogger { + level: LevelFilter::Off, + _priv: (), +}; + +#[cfg(feature = "simple_shared")] +pub fn windbg_simple_combo(log_lvl: LevelFilter) -> Box { + match log_lvl { + LevelFilter::Error => Box::new(WINDBG_L1), + LevelFilter::Warn => Box::new(WINDBG_L2), + LevelFilter::Info => Box::new(WINDBG_L3), + LevelFilter::Debug => Box::new(WINDBG_L4), + LevelFilter::Trace => Box::new(WINDBG_L5), + LevelFilter::Off => Box::new(WINDBG_L0), + } +} +#[cfg(feature = "simple_shared")] +impl simplelog::SharedLogger for WinDbgLogger { + // allows using with simplelog's CombinedLogger + fn level(&self) -> LevelFilter { + self.level + } + fn config(&self) -> Option<&simplelog::Config> { + None + } + fn as_log(self: Box) -> Box { + Box::new(*self) + } +} + +/// Convert logging levels to shorter and more visible icons +pub fn iconify(lvl: log::Level) -> char { + match lvl { + Level::Error => '❗', + Level::Warn => '⚠', + Level::Info => 'ⓘ', + Level::Debug => 'ⓓ', + Level::Trace => 'ⓣ', + } +} + +use std::sync::OnceLock; +pub fn is_thread_state() -> &'static bool { + set_thread_state(false) +} +pub fn set_thread_state(is: bool) -> &'static bool { + // accessor function to avoid get_or_init on every call + // (lazycell allows doing that without an extra function) + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| is) +} + +use regex::Regex; +macro_rules! regex { + ($re:literal $(,)?) => {{ + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| regex::Regex::new($re).unwrap()) + }}; +} +fn clean_name(path: Option<&str>) -> String { + let re_ext: &Regex = regex!(r"\..*$"); // shorten source file name, no src/ no .rs ext + let re_src: &Regex = regex!(r"src[\\/]"); + // remove extension and src paths + if let Some(p) = path { + re_src.replace(&re_ext.replace(p, ""), "").to_string() + } else { + "?".to_string() + } +} + +#[cfg(target_os = "windows")] +use winapi::um::processthreadsapi::GetCurrentThreadId; +impl log::Log for WinDbgLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= self.level + } + + fn log(&self, record: &Record) { + #[cfg(not(target_os = "windows"))] + let thread_id = ""; + #[cfg(target_os = "windows")] + let thread_id = if *is_thread_state() { + format!("{}¦", unsafe { GetCurrentThreadId() }) + } else { + "".to_string() + }; + if self.enabled(record.metadata()) { + let s = format!( + "{}{}{}:{} {}", + thread_id, + iconify(record.level()), + clean_name(record.file()), + record.line().unwrap_or(0), + record.args() + ); + output_debug_string(&s); + } + } + + fn flush(&self) {} +} + +/// Calls the `OutputDebugString` API to log a string. +/// +/// On non-Windows platforms, this function does nothing. +/// +/// See [`OutputDebugStringW`](https://docs.microsoft.com/en-us/windows/win32/api/debugapi/nf-debugapi-outputdebugstringw). +pub fn output_debug_string(s: &str) { + #[cfg(windows)] + { + let len = s.encode_utf16().count() + 1; + let mut s_utf16: Vec = Vec::with_capacity(len); + s_utf16.extend(s.encode_utf16()); + s_utf16.push(0); + unsafe { + OutputDebugStringW(&s_utf16[0]); + } + } + #[cfg(not(windows))] + { + let _ = s; + } +} + +#[cfg(windows)] +extern "stdcall" { + fn OutputDebugStringW(chars: *const u16); + fn IsDebuggerPresent() -> i32; +} + +/// Checks whether a debugger is attached to the current process. +/// +/// On non-Windows platforms, this function always returns `false`. +/// +/// See [`IsDebuggerPresent`](https://docs.microsoft.com/en-us/windows/win32/api/debugapi/nf-debugapi-isdebuggerpresent). +pub fn is_debugger_present() -> bool { + #[cfg(windows)] + { + unsafe { IsDebuggerPresent() != 0 } + } + #[cfg(not(windows))] + { + false + } +} + +/// Sets the `WinDbgLogger` as the currently-active logger. +/// +/// If an error occurs when registering `WinDbgLogger` as the current logger, this function will +/// output a warning and will return normally. It will not panic. +/// This behavior was chosen because `WinDbgLogger` is intended for use in debugging. +/// Panicking would disrupt debugging and introduce new failure modes. It would also create +/// problems for mixed-mode debugging, where Rust code is linked with C/C++ code. +pub fn init() { + match log::set_logger(&WINDBG_LOGGER) { + Ok(()) => {} //↓ there's really nothing we can do about it. + Err(_) => { + output_debug_string( + "Warning: Failed to register WinDbgLogger as the current Rust logger.\r\n", + ); + } + } +} + +macro_rules! define_init_at_level { + ($func:ident, $level:ident) => { + /// This can be called from C/C++ code to register the debug logger. + /// + /// For Windows DLLs that have statically linked an instance of `win_dbg_logger` into + /// them, `DllMain` should call `win_dbg_logger_init_()` from the `DLL_PROCESS_ATTACH` + /// handler, e.g.: + /// + /// ```ignore + /// extern "C" void __cdecl rust_win_dbg_logger_init_debug(); // Calls into Rust code + /// BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD reason, LPVOID reserved) { + /// switch (reason) { + /// case DLL_PROCESS_ATTACH: + /// rust_win_dbg_logger_init_debug(); + /// // ... + /// } + /// // ... + /// } + /// ``` + /// + /// For Windows executables that have statically linked an instance of `win_dbg_logger` + /// into them, call `win_dbg_logger_init_()` during app startup. + #[no_mangle] + pub extern "C" fn $func() { + init(); + log::set_max_level(LevelFilter::$level); + } + }; +} + +define_init_at_level!(rust_win_dbg_logger_init_trace, Trace); +define_init_at_level!(rust_win_dbg_logger_init_info, Info); +define_init_at_level!(rust_win_dbg_logger_init_debug, Debug); +define_init_at_level!(rust_win_dbg_logger_init_warn, Warn); +define_init_at_level!(rust_win_dbg_logger_init_error, Error);