diff --git a/Scripts/qa-keypath-release-smoke.sh b/Scripts/qa-keypath-release-smoke.sh index 1a4aa212b..381cdd6f0 100755 --- a/Scripts/qa-keypath-release-smoke.sh +++ b/Scripts/qa-keypath-release-smoke.sh @@ -3,7 +3,11 @@ set -euo pipefail APP_PATH="${APP_PATH:-/Applications/KeyPath.app}" CLI="${KEYPATH_CLI:-$APP_PATH/Contents/MacOS/keypath-cli}" -CONFIG_DIR="${KEYPATH_CONFIG_DIR:-$HOME/.config/keypath}" +# The installed CLI intentionally uses KeyPath's canonical user config path. +# Keep the smoke backup and fixtures on that same path; accepting an override +# here would only redirect the harness while the CLI continued mutating the +# real config. +CONFIG_DIR="$HOME/.config/keypath" CONFIG_PATH="$CONFIG_DIR/keypath.kbd" CONFIG_REAL_DIR="$(python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$CONFIG_DIR")" KANATA_BIN="${KEYPATH_KANATA_BIN:-$APP_PATH/Contents/Library/KeyPath/Kanata Engine.app/Contents/MacOS/kanata}" @@ -54,6 +58,17 @@ assert_contains() { fi } +assert_matches() { + local haystack="$1" + local pattern="$2" + local label="$3" + if [[ ! "$haystack" =~ $pattern ]]; then + echo "error: $label did not match expected pattern:" >&2 + echo " $pattern" >&2 + exit 1 + fi +} + wait_for_tcp() { local probe="$TMP_DIR/tcp-probe.log" local deadline=$((SECONDS + TCP_TIMEOUT_SECONDS)) @@ -331,7 +346,7 @@ prepare_custom_rule_fixtures apply_and_verify "custom rule families, app-specific aliases, and bundled Kanata validation" config="$("$CLI" config show --no-json --quiet)" -assert_contains "$config" "z x" "simple remap config" +assert_matches "$config" 'Description: Maps z to x' "simple remap config" assert_contains "$config" "tap-hold" "tap-hold config" assert_contains "$config" "(multi lctl lmet lalt lsft)" "hyper modifier config" assert_contains "$config" "f13" "function key config source" diff --git a/Sources/KeyPathApp/Info.plist b/Sources/KeyPathApp/Info.plist index 15a5286f0..f86e321df 100644 --- a/Sources/KeyPathApp/Info.plist +++ b/Sources/KeyPathApp/Info.plist @@ -11,7 +11,7 @@ CFBundleDisplayName KeyPath CFBundleVersion - 9 + 10 CFBundleShortVersionString 1.0.1 CFBundlePackageType diff --git a/Sources/KeyPathCore/KeyPathHelperContract.swift b/Sources/KeyPathCore/KeyPathHelperContract.swift index 6fb936c8d..1a412e62e 100644 --- a/Sources/KeyPathCore/KeyPathHelperContract.swift +++ b/Sources/KeyPathCore/KeyPathHelperContract.swift @@ -1,5 +1,5 @@ /// Shared identity and compatibility contract for the privileged helper. public enum KeyPathHelperContract { /// Version returned by the helper XPC service and packaged in its Info.plist. - public static let version = "1.3.0" + public static let version = "1.3.1" } diff --git a/Sources/KeyPathHelper/HelperService.swift b/Sources/KeyPathHelper/HelperService.swift index 1dad57286..c9d17c8ee 100644 --- a/Sources/KeyPathHelper/HelperService.swift +++ b/Sources/KeyPathHelper/HelperService.swift @@ -141,6 +141,16 @@ class HelperService: NSObject, HelperProtocol { executePrivilegedOperation( name: "startKanataService", operation: { + let enableResult = Self.run( + "/bin/launchctl", + ["enable", Self.kanataServiceTarget], + timeout: 15 + ) + guard enableResult.status == 0 else { + throw HelperError.operationFailed( + "Failed to enable KeyPath Kanata service: \(enableResult.out)" + ) + } let result = Self.run( "/bin/launchctl", ["kickstart", Self.kanataServiceTarget], @@ -161,6 +171,19 @@ class HelperService: NSObject, HelperProtocol { executePrivilegedOperation( name: "stopKanataService", operation: { + // com.keypath.kanata is KeepAlive. Disable it before signaling the + // process so launchd does not immediately respawn it while the CLI + // is waiting for the stopped postcondition. + let disableResult = Self.run( + "/bin/launchctl", + ["disable", Self.kanataServiceTarget], + timeout: 15 + ) + guard disableResult.status == 0 else { + throw HelperError.operationFailed( + "Failed to disable KeyPath Kanata service: \(disableResult.out)" + ) + } let result = Self.run( "/bin/launchctl", ["kill", "SIGTERM", Self.kanataServiceTarget], @@ -175,6 +198,13 @@ class HelperService: NSObject, HelperProtocol { return } guard result.status == 0 else { + // Preserve the caller's pre-stop enabled state when the + // signal itself fails for an unexpected reason. + _ = Self.run( + "/bin/launchctl", + ["enable", Self.kanataServiceTarget], + timeout: 15 + ) throw HelperError.operationFailed( "Failed to stop KeyPath Kanata service: \(result.out)" ) @@ -189,6 +219,16 @@ class HelperService: NSObject, HelperProtocol { executePrivilegedOperation( name: "restartKanataService", operation: { + let enableResult = Self.run( + "/bin/launchctl", + ["enable", Self.kanataServiceTarget], + timeout: 15 + ) + guard enableResult.status == 0 else { + throw HelperError.operationFailed( + "Failed to enable KeyPath Kanata service: \(enableResult.out)" + ) + } let result = Self.run( "/bin/launchctl", ["kickstart", "-k", Self.kanataServiceTarget], diff --git a/Sources/KeyPathHelper/Info.plist b/Sources/KeyPathHelper/Info.plist index 9c278a54e..93399b7f0 100644 --- a/Sources/KeyPathHelper/Info.plist +++ b/Sources/KeyPathHelper/Info.plist @@ -12,10 +12,10 @@ CFBundleShortVersionString - 1.3.0 + 1.3.1 CFBundleVersion - 3 + 4 CFBundleInfoDictionaryVersion diff --git a/Tests/KeyPathTests/Lint/HelperServiceKeepAliveLifecycleLintTests.swift b/Tests/KeyPathTests/Lint/HelperServiceKeepAliveLifecycleLintTests.swift new file mode 100644 index 000000000..3863bf967 --- /dev/null +++ b/Tests/KeyPathTests/Lint/HelperServiceKeepAliveLifecycleLintTests.swift @@ -0,0 +1,49 @@ +import Foundation +import XCTest + +final class HelperServiceKeepAliveLifecycleLintTests: XCTestCase { + func testHelperDisablesKeepAliveServiceBeforeStoppingIt() throws { + let source = try helperServiceSource() + let stopBody = try functionBody(named: "stopKanataService", in: source) + + let disable = try XCTUnwrap(stopBody.range(of: "[\"disable\", Self.kanataServiceTarget]")) + let signal = try XCTUnwrap(stopBody.range(of: "[\"kill\", \"SIGTERM\", Self.kanataServiceTarget]")) + let restore = try XCTUnwrap( + stopBody.range( + of: "[\"enable\", Self.kanataServiceTarget]", + range: signal.upperBound ..< stopBody.endIndex + ) + ) + XCTAssertLessThan(disable.lowerBound, signal.lowerBound) + XCTAssertLessThan(signal.lowerBound, restore.lowerBound) + } + + func testHelperReenablesServiceBeforeStartingOrRestartingIt() throws { + let source = try helperServiceSource() + + for functionName in ["startKanataService", "restartKanataService"] { + let body = try functionBody(named: functionName, in: source) + let enable = try XCTUnwrap(body.range(of: "[\"enable\", Self.kanataServiceTarget]")) + let kickstart = try XCTUnwrap(body.range(of: "[\"kickstart\"")) + XCTAssertLessThan(enable.lowerBound, kickstart.lowerBound, functionName) + } + } + + private func helperServiceSource() throws -> String { + let testFile = URL(fileURLWithPath: #filePath) + let repositoryRoot = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceURL = repositoryRoot.appendingPathComponent("Sources/KeyPathHelper/HelperService.swift") + return try String(contentsOf: sourceURL, encoding: .utf8) + } + + private func functionBody(named name: String, in source: String) throws -> Substring { + let start = try XCTUnwrap(source.range(of: "func \(name)(")) + let remainder = source[start.lowerBound...] + let nextFunction = remainder.dropFirst().range(of: "\n func ") + return nextFunction.map { remainder[..<$0.lowerBound] } ?? remainder + } +} diff --git a/docs/bugs/cli-service-control-helper-bypass.md b/docs/bugs/cli-service-control-helper-bypass.md index 292fcf8ee..b8d09732a 100644 --- a/docs/bugs/cli-service-control-helper-bypass.md +++ b/docs/bugs/cli-service-control-helper-bypass.md @@ -37,3 +37,17 @@ on the existing installer privilege broker. `{"restarted": true}`. - The installed app returns to an operational state with a fresh helper, running Kanata, and healthy VirtualHID. + +## KeepAlive follow-up + +Local release acceptance found that routing stop through the helper was necessary but not +sufficient. The helper initially sent `SIGTERM` to the registered KeepAlive job. Launchd accepted +the signal and immediately respawned Kanata, so the CLI's stopped postcondition timed out and +reported `Could not stop Kanata service`. + +The helper now disables `system/com.keypath.kanata` before signaling it. Start and restart +explicitly re-enable the job before kickstart, and an unexpected signal failure restores the +enabled state. The helper contract advanced to 1.3.1 so installations cannot retain the earlier +behavior while reporting the helper as fresh. A lifecycle lint test preserves the required +disable-before-kill and enable-before-kickstart ordering; installed-app acceptance verifies the +real launchd transition.