|
#!/usr/bin/env python3 |
|
"""Install built-in Caps Lock: tap ✔️, Shift+tap ❌, hold real Caps Lock.""" |
|
import copy |
|
import datetime |
|
import json |
|
import os |
|
from pathlib import Path |
|
import shlex |
|
import shutil |
|
import subprocess |
|
import sys |
|
import tempfile |
|
|
|
DESCRIPTION = "Caps Lock: ✔️ / ❌ / native hold (built-in keyboard)" |
|
NATIVE_SOURCE = r''' |
|
#include <Carbon/Carbon.h> |
|
#include <CoreGraphics/CoreGraphics.h> |
|
#import <AppKit/AppKit.h> |
|
#include <IOKit/IOKitLib.h> |
|
#include <IOKit/hidsystem/IOHIDLib.h> |
|
#include <IOKit/hidsystem/IOHIDParameter.h> |
|
#include <IOKit/hidsystem/IOHIDShared.h> |
|
#include <stdbool.h> |
|
#include <stdio.h> |
|
#include <stdlib.h> |
|
#include <string.h> |
|
#include <unistd.h> |
|
|
|
static CGEventRef text_event(bool down, const UniChar *text, UniCharCount length) { |
|
CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStatePrivate); |
|
CGEventRef event = CGEventCreateKeyboardEvent(source, 0, down); |
|
if (source) CFRelease(source); |
|
if (event) { |
|
CGEventSetFlags(event, 0); |
|
CGEventKeyboardSetUnicodeString(event, length, text); |
|
} |
|
return event; |
|
} |
|
|
|
static int insert_symbol(bool cross, bool verify_only) { |
|
const UniChar check[] = {0x2714, 0xfe0f}; |
|
const UniChar negative[] = {0x274c}; |
|
const UniChar *text = cross ? negative : check; |
|
UniCharCount length = cross ? 1 : 2; |
|
CGEventRef down = text_event(true, text, length); |
|
CGEventRef up = text_event(false, text, length); |
|
if (!down || !up) { |
|
if (down) CFRelease(down); |
|
if (up) CFRelease(up); |
|
return 1; |
|
} |
|
if (verify_only) { |
|
UniChar actual[2] = {0, 0}; |
|
UniCharCount count = 0; |
|
CGEventKeyboardGetUnicodeString(down, 2, &count, actual); |
|
bool valid = count == length && !memcmp(actual, text, length * sizeof(UniChar)) && CGEventGetFlags(down) == 0; |
|
CFRelease(down); CFRelease(up); |
|
return valid ? 0 : 1; |
|
} |
|
if (!CGPreflightPostEventAccess()) { |
|
fprintf(stderr, "Direct Unicode input needs Device Control and Data Access (Accessibility on older macOS) permission for Karabiner-Console-User-Server.\n"); |
|
CFRelease(down); CFRelease(up); return 2; |
|
} |
|
@autoreleasepool { |
|
NSRunningApplication *application = [[NSWorkspace sharedWorkspace] frontmostApplication]; |
|
pid_t target = application ? [application processIdentifier] : 0; |
|
if (target <= 0) { CFRelease(down); CFRelease(up); return 1; } |
|
CGEventPostToPid(target, down); |
|
usleep(10000); |
|
CGEventPostToPid(target, up); |
|
} |
|
CFRelease(down); CFRelease(up); |
|
printf("Inserted %s directly.\n", cross ? "cross" : "check"); |
|
return 0; |
|
} |
|
|
|
static int remove_unicode_layout(void) { |
|
CFArrayRef sources = TISCreateInputSourceList(NULL, true); |
|
TISInputSourceRef target = NULL; |
|
for (CFIndex i = 0; sources && i < CFArrayGetCount(sources); ++i) { |
|
TISInputSourceRef source = (TISInputSourceRef)CFArrayGetValueAtIndex(sources, i); |
|
CFStringRef identifier = TISGetInputSourceProperty(source, kTISPropertyInputSourceID); |
|
if (identifier && CFEqual(identifier, CFSTR("com.apple.keylayout.UnicodeHexInput"))) target = source; |
|
} |
|
OSStatus result = noErr; |
|
if (target && CFBooleanGetValue(TISGetInputSourceProperty(target, kTISPropertyInputSourceIsEnabled))) { |
|
if (CFBooleanGetValue(TISGetInputSourceProperty(target, kTISPropertyInputSourceIsSelected))) { |
|
bool selected = false; |
|
for (CFIndex i = 0; i < CFArrayGetCount(sources); ++i) { |
|
TISInputSourceRef other = (TISInputSourceRef)CFArrayGetValueAtIndex(sources, i); |
|
CFStringRef type = TISGetInputSourceProperty(other, kTISPropertyInputSourceType); |
|
CFBooleanRef enabled = TISGetInputSourceProperty(other, kTISPropertyInputSourceIsEnabled); |
|
if (other != target && type && CFEqual(type, kTISTypeKeyboardLayout) && enabled && CFBooleanGetValue(enabled)) { |
|
selected = TISSelectInputSource(other) == noErr; |
|
if (selected) break; |
|
} |
|
} |
|
if (!selected) { CFRelease(sources); fprintf(stderr, "Switch to English or Japanese first.\n"); return 1; } |
|
} |
|
result = TISDisableInputSource(target); |
|
} |
|
if (sources) CFRelease(sources); |
|
if (result) fprintf(stderr, "Cannot remove Unicode Hex Input (error %d).\n", (int)result); |
|
return result ? 1 : 0; |
|
} |
|
|
|
static bool wait_for_caps_state(io_connect_t connection, bool expected) { |
|
for (int attempt = 0; attempt < 20; ++attempt) { |
|
bool actual = !expected; |
|
kern_return_t result = IOHIDGetModifierLockState(connection, kIOHIDCapsLockState, &actual); |
|
bool flags = (CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState) & kCGEventFlagMaskAlphaShift) != 0; |
|
if (!result && actual == expected && flags == expected) return true; |
|
usleep(50000); |
|
} |
|
return false; |
|
} |
|
|
|
int main(int argc, char **argv) { |
|
if (argc != 2) return 1; |
|
if (!strcmp(argv[1], "check")) return insert_symbol(false, false); |
|
if (!strcmp(argv[1], "cross")) return insert_symbol(true, false); |
|
if (!strcmp(argv[1], "verify-unicode")) return insert_symbol(false, true) || insert_symbol(true, true); |
|
if (!strcmp(argv[1], "post-access")) { printf("%s\n", CGPreflightPostEventAccess() ? "allowed" : "denied"); return 0; } |
|
if (!strcmp(argv[1], "remove-unicode")) return remove_unicode_layout(); |
|
bool test = !strcmp(argv[1], "self-test"); |
|
if (!test && strcmp(argv[1], "toggle")) return 1; |
|
io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOHIDSystem")); |
|
if (!service) { fprintf(stderr, "IOHIDSystem unavailable.\n"); return 1; } |
|
io_connect_t connection = IO_OBJECT_NULL; |
|
kern_return_t result = IOServiceOpen(service, mach_task_self(), kIOHIDParamConnectType, &connection); |
|
IOObjectRelease(service); |
|
if (result) { fprintf(stderr, "Cannot open IOHIDSystem: 0x%x\n", result); return 1; } |
|
bool original = false; |
|
result = IOHIDGetModifierLockState(connection, kIOHIDCapsLockState, &original); |
|
if (result) { IOServiceClose(connection); return 1; } |
|
result = IOHIDSetModifierLockState(connection, kIOHIDCapsLockState, !original); |
|
bool passed = !result && wait_for_caps_state(connection, !original); |
|
if (test) { |
|
kern_return_t restore = IOHIDSetModifierLockState(connection, kIOHIDCapsLockState, original); |
|
bool restored = !restore && wait_for_caps_state(connection, original); |
|
passed = passed && restored; |
|
} |
|
IOServiceClose(connection); |
|
if (!passed) fprintf(stderr, "Caps Lock native verification failed.\n"); |
|
return passed ? 0 : 1; |
|
} |
|
''' |
|
|
|
|
|
def make_rule(helper): |
|
def manipulator(shift, action): |
|
modifiers = {"optional": ["caps_lock"]} |
|
if shift: |
|
modifiers["mandatory"] = ["shift"] |
|
return { |
|
"type": "basic", |
|
"from": {"key_code": "caps_lock", "modifiers": modifiers}, |
|
"to": [{"key_code": "vk_none"}], |
|
"to_if_alone": [{"shell_command": shlex.quote(str(helper)) + " " + action}], |
|
"to_if_held_down": [{"shell_command": shlex.quote(str(helper)) + " toggle"}], |
|
"parameters": {"basic.to_if_alone_timeout_milliseconds": 250, "basic.to_if_held_down_threshold_milliseconds": 250}, |
|
"conditions": [{"type": "device_if", "identifiers": [{"is_built_in_keyboard": True}]}], |
|
} |
|
return {"description": DESCRIPTION, "manipulators": [manipulator(True, "cross"), manipulator(False, "check")]} |
|
|
|
|
|
def update_config(config, rule): |
|
config = copy.deepcopy(config) |
|
profile = next((p for p in config.get("profiles", []) if p.get("selected")), None) |
|
if profile is None: |
|
raise RuntimeError("Open Karabiner-Elements and select a profile first.") |
|
mappings = list(profile.get("simple_modifications", [])) |
|
for device in profile.get("devices", []): |
|
mappings.extend(device.get("simple_modifications", [])) |
|
if any(mapping.get("from", {}).get("key_code") == "caps_lock" for mapping in mappings): |
|
raise RuntimeError("Remove the existing Caps Lock Simple Modification in Karabiner first, then rerun this installer.") |
|
rules = profile.setdefault("complex_modifications", {}).setdefault("rules", []) |
|
names = {DESCRIPTION, "caps: tap ✔️, shift-tap ❌, hold caps lock"} |
|
rules[:] = [rule] + [old for old in rules if old.get("description") not in names] |
|
return config |
|
|
|
|
|
def write_atomic(path, data, mode=0o600): |
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
descriptor, temporary = tempfile.mkstemp(prefix=".caps-symbols-", dir=path.parent) |
|
try: |
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream: |
|
stream.write(data) |
|
os.chmod(temporary, mode) |
|
os.replace(temporary, path) |
|
finally: |
|
if os.path.exists(temporary): |
|
os.unlink(temporary) |
|
|
|
|
|
def main(): |
|
if any(argument != "--remove-unicode-hex-input" for argument in sys.argv[1:]): |
|
raise RuntimeError("Usage: install-caps-symbols.py [--remove-unicode-hex-input]") |
|
if sys.platform != "darwin": |
|
raise RuntimeError("This installer is for macOS.") |
|
root = Path.home() / ".config/karabiner" |
|
config_path = root / "karabiner.json" |
|
cli = Path("/Library/Application Support/org.pqrs/Karabiner-Elements/bin/karabiner_cli") |
|
if not cli.is_file() or not config_path.is_file(): |
|
raise RuntimeError("Install and open Karabiner-Elements first, then rerun this installer.") |
|
subprocess.run(["/usr/bin/xcode-select", "-p"], check=True, stdout=subprocess.DEVNULL) |
|
config = json.loads(config_path.read_text(encoding="utf-8")) |
|
helper = root / "bin/caps_symbols_native" |
|
update_config(config, make_rule(helper)) # Preflight before changing anything. |
|
asset = root / "assets/complex_modifications/caps_symbols.json" |
|
source_path = root / "assets/helpers/caps_symbols_native.m" |
|
legacy_asset = root / "assets/complex_modifications/doll_caps.json" |
|
backup = root / "caps-symbols-backups" / datetime.datetime.now().strftime("%Y%m%d-%H%M%S-%f") |
|
backup.mkdir(parents=True) |
|
for path in [config_path, asset, legacy_asset, source_path, helper]: |
|
if path.exists(): |
|
destination = backup / path.relative_to(root) |
|
destination.parent.mkdir(parents=True, exist_ok=True) |
|
shutil.copy2(path, destination) |
|
with tempfile.TemporaryDirectory(prefix="caps-symbols-") as temporary: |
|
source = Path(temporary) / "helper.m" |
|
binary = Path(temporary) / "helper" |
|
source.write_text(NATIVE_SOURCE, encoding="utf-8") |
|
subprocess.run(["/usr/bin/clang", str(source), "-framework", "IOKit", "-framework", "CoreGraphics", "-framework", "Carbon", "-framework", "AppKit", "-o", str(binary)], check=True) |
|
subprocess.run([str(binary), "verify-unicode"], check=True) |
|
rule = make_rule(helper) |
|
artifact = {"title": "Caps Lock symbols", "rules": [rule]} |
|
candidate = Path(temporary) / "caps_symbols.json" |
|
candidate.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
|
subprocess.run([str(cli), "--lint-complex-modifications", str(candidate)], check=True) |
|
subprocess.run([str(binary), "self-test"], check=True) |
|
# Re-read immediately before writing so unrelated intervening edits survive. |
|
config = update_config(json.loads(config_path.read_text(encoding="utf-8")), rule) |
|
if "--remove-unicode-hex-input" in sys.argv[1:] and "UnicodeHexInput" in json.dumps(config): |
|
raise RuntimeError("Other rules still use Unicode Hex Input. Update those rules before removing that input source.") |
|
helper.parent.mkdir(parents=True, exist_ok=True) |
|
staging = helper.with_name(helper.name + ".new") |
|
shutil.copyfile(binary, staging) |
|
os.chmod(staging, 0o755) |
|
staging.replace(helper) |
|
write_atomic(source_path, NATIVE_SOURCE) |
|
write_atomic(asset, candidate.read_text(encoding="utf-8")) |
|
if legacy_asset.exists(): |
|
previous = json.loads(legacy_asset.read_text(encoding="utf-8")) |
|
managed_names = {DESCRIPTION, "caps: tap ✔️, shift-tap ❌, hold caps lock"} |
|
if previous.get("rules") and all(item.get("description") in managed_names for item in previous["rules"]): |
|
write_atomic(legacy_asset, candidate.read_text(encoding="utf-8")) |
|
write_atomic(config_path, json.dumps(config, ensure_ascii=False, indent=4) + "\n") |
|
if "--remove-unicode-hex-input" in sys.argv[1:]: |
|
subprocess.run([str(helper), "remove-unicode"], check=True) |
|
print("Installed: tap Caps Lock → ✔️; Shift+tap → ❌; hold 250 ms → Caps Lock.") |
|
print("Built-in keyboard only; clipboard untouched.") |
|
print("Symbols are inserted directly; input sources are never switched or added.") |
|
print("Enable Karabiner-Console-User-Server in System Settings → Privacy & Security → Device Control and Data Access (Accessibility on older macOS).") |
|
print("If missing, add: /Library/Application Support/org.pqrs/Karabiner-Elements/Karabiner-Console-User-Server.app") |
|
print("Test both symbols and hold → type a → hold → type a in a blank text document.") |
|
print("Backup:", backup) |
|
|
|
|
|
if __name__ == "__main__": |
|
try: |
|
main() |
|
except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as error: |
|
print("Installation stopped:", error, file=sys.stderr) |
|
print("Karabiner must already be running with its required permissions; Apple's Command Line Tools must be installed.", file=sys.stderr) |
|
sys.exit(1) |