Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions Scripts/qa-keypath-release-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion Sources/KeyPathApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<key>CFBundleDisplayName</key>
<string>KeyPath</string>
<key>CFBundleVersion</key>
<string>9</string>
<string>10</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<key>CFBundlePackageType</key>
Expand Down
2 changes: 1 addition & 1 deletion Sources/KeyPathCore/KeyPathHelperContract.swift
Original file line number Diff line number Diff line change
@@ -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"
}
40 changes: 40 additions & 0 deletions Sources/KeyPathHelper/HelperService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@
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],
Expand All @@ -161,6 +171,19 @@
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],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear the persistent disable in every start path

launchctl disable records a persistent override for this label, but only the helper's CLI start/restart methods issue the corresponding enable. After keypath service stop, ordinary app paths—including Settings, wizard, and auto-start—use ServiceLifecycleCoordinator.startKanata() (ServiceLifecycleCoordinator.swift:193-231), whose SMAppService register/kickstart flow never enables the label, so those paths cannot restart Kanata until the user runs a CLI start/restart. Route this transition through the canonical lifecycle coordinator or ensure every start path clears the override.

AGENTS.md reference: AGENTS.md:L331-L332

Useful? React with 👍 / 👎.

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],
Expand All @@ -175,6 +198,13 @@
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)"
)
Expand All @@ -189,6 +219,16 @@
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],
Expand Down Expand Up @@ -822,7 +862,7 @@
return (
result.status,
result.out
+ "\nVirtualHID activation may be waiting for macOS approval. Open System Settings > Privacy & Security and approve the Karabiner VirtualHIDDevice system extension, then retry repair."

Check warning on line 865 in Sources/KeyPathHelper/HelperService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 204 characters (line_length)

Check warning on line 865 in Sources/KeyPathHelper/HelperService.swift

View workflow job for this annotation

GitHub Actions / code-quality

Line should be 200 characters or less; currently it has 204 characters (line_length)
)
}
return result
Expand Down
4 changes: 2 additions & 2 deletions Sources/KeyPathHelper/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@

<!-- Version (should match main app) -->
<key>CFBundleShortVersionString</key>
<string>1.3.0</string>
<string>1.3.1</string>

<key>CFBundleVersion</key>
<string>3</string>
<string>4</string>

<!-- Info dictionary version -->
<key>CFBundleInfoDictionaryVersion</key>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
14 changes: 14 additions & 0 deletions docs/bugs/cli-service-control-helper-bypass.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading