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.