From 93690c7c20b55043f85d84662c9204e4f8d5bf03 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Mon, 1 Jun 2026 13:06:45 -0700 Subject: [PATCH] feat(uninstall): opt-in removal of the VirtualHID driver (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-facing uninstall already tears down the daemon, helper, and app, but left the Karabiner VirtualHID driver installed, and InstallerEngine.uninstall's `_ = broker // Reserved for future privileged uninstall steps` was an unused hook. Add an opt-in "Also remove the virtual keyboard driver" checkbox to the uninstall dialog (default off — VHID is a shared component other tools like Karabiner-Elements may rely on). The flag threads UninstallKeyPathDialog → KanataViewModel → RuntimeCoordinator → InstallerEngine.uninstall, which now uses the broker to call uninstallVirtualHIDDrivers() before the main teardown (that self-destructs the privileged helper this step depends on). Best-effort: a VHID failure logs and continues so it never blocks removing KeyPath itself. Manual verification needed: the uninstall path is destructive and can't be exercised in automated tests. Verify a real uninstall with the toggle on (driver removed) and off (driver preserved) before relying on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Managers/RuntimeCoordinator.swift | 6 ++++-- .../UI/Dialogs/UninstallKeyPathDialog.swift | 17 ++++++++++++++- .../UI/ViewModels/KanataViewModel.swift | 4 ++-- .../Core/InstallerEngine.swift | 21 +++++++++++++++---- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift index 8d7181cdc..c3151d9bc 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift @@ -589,8 +589,10 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate { await installerEngine.inspectSystem() } - func uninstall(deleteConfig: Bool) async -> InstallerReport { - await installerEngine.uninstall(deleteConfig: deleteConfig, using: privilegeBroker) + func uninstall(deleteConfig: Bool, removeVirtualHID: Bool = false) async -> InstallerReport { + await installerEngine.uninstall( + deleteConfig: deleteConfig, removeVirtualHID: removeVirtualHID, using: privilegeBroker + ) } func runFullRepair(reason: String = "RuntimeCoordinator repair request") async -> InstallerReport { diff --git a/Sources/KeyPathAppKit/UI/Dialogs/UninstallKeyPathDialog.swift b/Sources/KeyPathAppKit/UI/Dialogs/UninstallKeyPathDialog.swift index 7deeb139e..d43df5cb4 100644 --- a/Sources/KeyPathAppKit/UI/Dialogs/UninstallKeyPathDialog.swift +++ b/Sources/KeyPathAppKit/UI/Dialogs/UninstallKeyPathDialog.swift @@ -11,6 +11,7 @@ struct UninstallKeyPathDialog: View { @State private var didSucceed = false @State private var hasScheduledQuit = false @State private var autoQuitWorkItem: DispatchWorkItem? + @State private var removeVirtualHID = false var body: some View { VStack(spacing: 20) { @@ -36,6 +37,20 @@ struct UninstallKeyPathDialog: View { .foregroundColor(.secondary) .multilineTextAlignment(.center) + // Optional: also remove the shared Karabiner virtual keyboard driver. + Toggle(isOn: $removeVirtualHID) { + VStack(alignment: .leading, spacing: 2) { + Text("Also remove the virtual keyboard driver") + .font(.caption) + Text("Other tools (e.g. Karabiner-Elements) may rely on it.") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .toggleStyle(.checkbox) + .disabled(isRunning) + .accessibilityIdentifier("uninstall-remove-vhid-toggle") + // Status if isRunning { ProgressView() @@ -90,7 +105,7 @@ struct UninstallKeyPathDialog: View { lastError = nil } - let report = await kanataManager.uninstall(deleteConfig: false) + let report = await kanataManager.uninstall(deleteConfig: false, removeVirtualHID: removeVirtualHID) await MainActor.run { isRunning = false diff --git a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift index a5b5d891c..1dee4ce73 100644 --- a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift +++ b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift @@ -477,8 +477,8 @@ class KanataViewModel { await manager.inspectSystemContext() } - func uninstall(deleteConfig: Bool) async -> InstallerReport { - await manager.uninstall(deleteConfig: deleteConfig) + func uninstall(deleteConfig: Bool, removeVirtualHID: Bool = false) async -> InstallerReport { + await manager.uninstall(deleteConfig: deleteConfig, removeVirtualHID: removeVirtualHID) } func runFullRepair(reason: String) async -> InstallerReport { diff --git a/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift b/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift index 28e3d61a6..3ac2c2aaa 100644 --- a/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift +++ b/Sources/KeyPathInstallationWizard/Core/InstallerEngine.swift @@ -601,10 +601,23 @@ public final class InstallerEngine { return report } - /// Execute uninstall via the existing coordinator (placeholder until uninstall recipes exist) - public func uninstall(deleteConfig: Bool, using broker: PrivilegeBroker) async -> InstallerReport { - AppLogger.shared.log("🗑️ [InstallerEngine] Starting uninstall (deleteConfig: \(deleteConfig))") - _ = broker // Reserved for future privileged uninstall steps + /// Execute uninstall via the existing coordinator. + /// - Parameter removeVirtualHID: when true, also tears down the Karabiner VirtualHID + /// driver. Off by default because the driver is a shared component other tools may use. + public func uninstall(deleteConfig: Bool, removeVirtualHID: Bool = false, using broker: PrivilegeBroker) async -> InstallerReport { + AppLogger.shared.log("🗑️ [InstallerEngine] Starting uninstall (deleteConfig: \(deleteConfig), removeVirtualHID: \(removeVirtualHID))") + + // Remove the VirtualHID driver BEFORE the main teardown: the coordinator uninstall + // self-destructs the privileged helper that this step depends on. Best-effort — a + // VHID failure must not block removing KeyPath itself. + if removeVirtualHID { + do { + try await broker.uninstallVirtualHIDDrivers() + AppLogger.shared.log("🗑️ [InstallerEngine] Virtual HID driver removed") + } catch { + AppLogger.shared.log("⚠️ [InstallerEngine] Virtual HID removal failed (continuing uninstall): \(error)") + } + } let start = Date() guard let coordinator = WizardDependencies.createUninstallCoordinator?() else {