#!/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(preferences, "HotkeyPrimaryActionButton", "custom hotkey rows must use the shared action button")
|
require(preferences, "ProStatusPill(text: title, style: .locked, compact: true)", "locked custom hotkey action must use the global Pro pill")
|
require(preferences, "if canRestore {", "restore-default action must only appear when a custom shortcut exists")
|
|
custom_row_start = preferences.find("private struct CustomizableHotkeyInfoRow")
|
custom_row_end = preferences.find("private struct HotkeyStatusPill")
|
if custom_row_start == -1 or custom_row_end == -1 or custom_row_end <= custom_row_start:
|
fail("could not inspect CustomizableHotkeyInfoRow layout")
|
custom_row = preferences[custom_row_start:custom_row_end]
|
if ".buttonStyle(.borderedProminent)" in custom_row:
|
fail("custom hotkey rows must not use the default blue borderedProminent Pro button")
|
|
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, Pro-consistent row UI, and 29-language copy are wired")
|
PY
|