#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" python3 - "$ROOT_DIR" <<'PY' import json import re import sys from pathlib import Path root = Path(sys.argv[1]) app_dir = root / "Apptag" localization_dir = app_dir / "Localization" EXPECTED_LANGUAGE_COUNT = 29 REQUIRED_HOTKEY_KEYS = [ "hotkeys.customize", "hotkeys.restoreDefault", "hotkeys.recording", "hotkeys.recordingInline", "hotkeys.recordingCancelled", "hotkeys.saved", "hotkeys.restored", "hotkeys.proRequired", "hotkeys.error.modifierRequired", "hotkeys.error.unsupportedKey", "hotkeys.error.reserved", "hotkeys.error.duplicate", "hotkeys.error.registrationFailed", "pro.feature.customHotkeys", "pro.feature.customHotkeys.benefit", "pro.prompt.customHotkeys", ] def fail(message: str) -> None: raise SystemExit(f"FAIL: {message}") def read(path: Path) -> str: try: return path.read_text(encoding="utf-8") except Exception as error: fail(f"could not read {path}: {error}") def require(source: str, needle: str, message: str) -> None: if needle not in source: fail(message) def function_body(source: str, signature: str) -> str: start = source.find(signature) if start == -1: fail(f"missing function signature: {signature}") brace = source.find("{", start) if brace == -1: fail(f"missing function body for: {signature}") depth = 0 for index in range(brace, len(source)): char = source[index] if char == "{": depth += 1 elif char == "}": depth -= 1 if depth == 0: return source[brace + 1:index] fail(f"unterminated function body for: {signature}") def load_json(path: Path) -> dict[str, str]: try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as error: fail(f"{path.name} is not valid JSON: {error}") if not isinstance(data, dict): fail(f"{path.name} must contain a JSON object") return data settings = read(app_dir / "LauncherHotkeySettings.swift") app = read(app_dir / "ApptagApp.swift") preferences = read(app_dir / "PreferencesView.swift") pro = read(app_dir / "ProEntitlement.swift") access_views = read(app_dir / "ProAccessViews.swift") defaults = read(app_dir / "AppDefaults.swift") quick_search = read(app_dir / "QuickSearch.swift") require(settings, 'private static let namespace = "customHotkeys.v1"', "custom hotkeys must use the new v1 namespace") require(settings, "defaultHotkey(for kind: LauncherHotkeyKind) -> LauncherHotkey", "default hotkey helper is missing") require(settings, "kind.hotkey", "default hotkey must remain the fixed built-in shortcut") require(settings, "ProEntitlementPolicy.isUnlocked(.customHotkeys)", "effective hotkey must be gated by Pro entitlement") require(settings, "structuralValidationError(for: saved) == nil", "stored hotkeys must pass structural validation before use") require(settings, "!duplicatesOtherEffectiveHotkey(saved, kind: kind)", "effective hotkey must reject duplicated saved shortcuts") require(settings, "effectiveHotkeyIgnoringDuplicateCheck(for:", "duplicate validation must avoid recursive effectiveHotkey calls") require(settings, "kEventKeyModifierFnMask", "Fn modifier support is required for the default Quick Search hotkey") require(settings, "modifiers == UInt32(kEventKeyModifierFnMask)", "Fn-only must be restricted explicitly") require(settings, "hotkey.keyCode == UInt32(kVK_Space)", "Fn-only must only be valid for Space") require(settings, "isReservedBySystem", "system-reserved shortcuts must be rejected") effective_body = function_body(settings, "static func effectiveHotkey(for kind: LauncherHotkeyKind) -> LauncherHotkey") if "validationError(for:" in effective_body: fail("effectiveHotkey must not call validationError because duplicate checks can recurse") validation_body = function_body(settings, "static func validationError(for hotkey: LauncherHotkey, kind: LauncherHotkeyKind) -> LauncherHotkeyValidationError?") require(validation_body, "structuralValidationError(for:", "public validation must run structural checks") require(validation_body, "duplicatesOtherEffectiveHotkey", "public validation must reject duplicates") apply_body = function_body(app, "func applyCustomHotkey(_ hotkey: LauncherHotkey, for kind: LauncherHotkeyKind) -> LauncherHotkeyCustomizationResult") require(apply_body, "guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else", "AppDelegate save path must be Pro-gated") require(apply_body, "LauncherHotkeySettings.validationError(for: hotkey, kind: kind)", "AppDelegate save path must validate the candidate") require(apply_body, "activateHotkey(hotkey, for: kind, persistCustom: true)", "custom save must go through transactional activation") restore_body = function_body(app, "func restoreDefaultHotkey(for kind: LauncherHotkeyKind) -> LauncherHotkeyCustomizationResult") require(restore_body, "guard ProEntitlementPolicy.isUnlocked(.customHotkeys) else", "restore default path must be Pro-gated") require(restore_body, "persistCustom: false", "restore default must clear custom storage after successful registration") activate_body = function_body(app, "private func activateHotkey(") try_register_index = activate_body.find("tryRegisterHotkey(hotkey") success_index = activate_body.find("case .success(let newRef):") if try_register_index == -1 or success_index == -1: fail("transactional registration body is incomplete") success_body = activate_body[success_index:] unregister_index = success_body.find("UnregisterEventHotKey(oldRef)") save_index = success_body.find("LauncherHotkeySettings.save") if unregister_index == -1 or save_index == -1: fail("transactional success branch is incomplete") if not (try_register_index < success_index and unregister_index < save_index): fail("custom hotkey must register first, then unregister old ref, then persist") require(activate_body, "return .registrationFailed(status)", "registration failure must not save the candidate") require(app, "observeProEntitlementChanges()", "AppDelegate must observe Pro entitlement changes") require(app, "registerConfiguredHotkeys()", "Pro entitlement changes must re-register effective hotkeys") require(app, "LauncherHotkeySettings.effectiveHotkey(for: .main).displayString", "menu title must display the effective App Grid shortcut") require(app, "LauncherHotkeySettings.effectiveHotkey(for: kind)", "hotkey registration must read effective shortcuts") require(app, "RegisterEventHotKey", "Carbon RegisterEventHotKey must remain the global hotkey backend") require(app, "UnregisterEventHotKey(oldRef)", "old hotkey ref must be released after successful replacement") for forbidden in ["CGEventTapCreate", "AXIsProcessTrusted", "IOHIDManagerCreate", "addGlobalMonitorForEvents(matching: .keyDown"]: if forbidden in app + preferences + settings + quick_search: fail(f"forbidden API used for custom hotkeys: {forbidden}") require(preferences, "presentSettingsProPrompt(for: .customHotkeys)", "free users must see the Pro prompt instead of recording") require(preferences, "NSEvent.addLocalMonitorForEvents(matching: .keyDown)", "recording must use local key-down capture in Settings") require(preferences, "Int(event.keyCode) == kVK_Escape", "Esc must cancel recording") require(preferences, "LauncherHotkeySettings.candidate(from: event)", "recording must normalize NSEvent into LauncherHotkey") require(preferences, ".applyCustomHotkey(candidate, for: kind)", "recording must call the AppDelegate transactional save path") require(preferences, "CustomizableHotkeyInfoRow", "Settings hotkey rows must expose custom controls") require(preferences, "StaticHotkeyInfoRow(", "internal App Grid Space shortcut must remain static") require(preferences, "LauncherHotkeySettings.effectiveHotkey(for: .main).displayString", "Settings must display effective App Grid shortcut") require(preferences, "LauncherHotkeySettings.effectiveHotkey(for: .quickSearch).displayString", "Settings must display effective Quick Search shortcut") require(pro, "case customHotkeys", "ProFeature.customHotkeys is missing") require(pro, "case .premiumThemes, .layoutImport, .layoutExport, .unlimitedNotes, .persistentAppSorting, .customHotkeys:", "customHotkeys must be unlocked only by the Pro access state") require(pro, "NotificationCenter.default.post(name: .tagLauncherProEntitlementChanged", "entitlement changes must notify hotkey registration") require(access_views, "case .customHotkeys:", "Pro prompt routing must include custom hotkeys") cleanup_body = function_body(defaults, "private static func removeShortcutCustomizationDefaults()") if "customHotkeys.v1" in cleanup_body: fail("legacy cleanup must not delete new custom hotkey storage") language_files = sorted(localization_dir.glob("*.json")) if len(language_files) != EXPECTED_LANGUAGE_COUNT: fail(f"expected {EXPECTED_LANGUAGE_COUNT} localization files, found {len(language_files)}") for path in language_files: data = load_json(path) missing = [ key for key in REQUIRED_HOTKEY_KEYS if not isinstance(data.get(key), str) or not data[key].strip() ] if missing: fail(f"{path.name} missing custom hotkey translations: {', '.join(missing)}") if "%status%" not in data["hotkeys.error.registrationFailed"]: fail(f"{path.name} hotkeys.error.registrationFailed must keep %status% placeholder") print("PASS Pro custom hotkeys QA: Pro gating, transactional Carbon registration, duplicate-safe effective shortcuts, local recording, and 29-language copy are wired") PY