Skip to content
Open
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
174 changes: 174 additions & 0 deletions Services/UTMUSBHelperController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//
// App side of the XPC protocol to UTMUSBHelper (§ Services/
// UTMUSBHelperProtocol.swift). Handles registering the daemon through
// SMAppService (macOS 13+, the replacement for the deprecated SMJobBless):
// no Apple-approved entitlement, just the ordinary administrator
// authorization prompt, shown ONCE at install time.
//

import Foundation
#if os(macOS)
import ServiceManagement
#endif

enum UTMUSBHelperError: LocalizedError {
case unavailable
case requiresApproval
case registrationFailed(String)
case xpcFailed(String)
case operationFailed(String)

var errorDescription: String? {
switch self {
case .unavailable:
return NSLocalizedString("Richiede macOS 13 o successivo.", comment: "UTMUSBHelperError")
case .requiresApproval:
return NSLocalizedString("Approva UTMUSBHelper in Impostazioni di Sistema → Generale → Elementi login ed estensioni, poi riprova.", comment: "UTMUSBHelperError")
case .registrationFailed(let m):
return String.localizedStringWithFormat(NSLocalizedString("Impossibile installare l'helper USB: %@", comment: "UTMUSBHelperError"), m)
case .xpcFailed(let m):
return String.localizedStringWithFormat(NSLocalizedString("Comunicazione con l'helper USB fallita: %@", comment: "UTMUSBHelperError"), m)
case .operationFailed(let m):
return m
}
}
}

#if os(macOS)
@available(macOS 13.0, *)
final class UTMUSBHelperController {
static let shared = UTMUSBHelperController()

private let plistName = "com.utmapp.UTMUSBHelper.plist"
private var service: SMAppService { SMAppService.daemon(plistName: plistName) }

var isRegistered: Bool { service.status == .enabled }

/// Registra il demone se serve — il prompt di autorizzazione di sistema
/// compare SOLO la prima volta in assoluto.
func registerIfNeeded() throws {
switch service.status {
case .enabled:
return

case .requiresApproval:
SMAppService.openSystemSettingsLoginItems()
throw UTMUSBHelperError.requiresApproval

// `.notFound` NON va trattato come errore fatale, malgrado il nome
// e la documentazione ("il servizio non è nel bundle").
//
// Verificato dal log di sistema su macOS 26: prima della prima
// registrazione lo stato è .notFound (3), non .notRegistered (0).
// `smd` il plist lo legge correttamente — stampa Label e
// BundleProgram giusti — e fallisce solo nel recuperare la
// "disposition" da backgroundtaskmanagementd, che di quel servizio
// non ha ancora alcun record: ovvio per un demone mai registrato.
// Nello stesso log altre app di sistema mostrano lo stesso stato 3
// nelle stesse condizioni.
//
// Trattarlo come fatale impediva la registrazione per sempre, con
// un messaggio fuorviante su un file che nel bundle c'era eccome.
// Si tenta quindi comunque, e si segnala errore solo se a fallire
// è register().
case .notRegistered, .notFound:
fallthrough

@unknown default:
do {
try service.register()
} catch {
throw UTMUSBHelperError.registrationFailed(error.localizedDescription)
}
if service.status == .requiresApproval {
SMAppService.openSystemSettingsLoginItems()
throw UTMUSBHelperError.requiresApproval
}
}
}

private func makeConnection() -> NSXPCConnection {
let c = NSXPCConnection(machServiceName: kUTMUSBHelperMachServiceName, options: .privileged)
c.remoteObjectInterface = NSXPCInterface(with: UTMUSBHelperProtocol.self)
c.resume()
return c
}

/// Wrapper comune per una chiamata XPC one-shot: risolve UNA sola volta
/// fra errorHandler, interruption e reply (qualunque arrivi prima), e
/// invalida sempre la connessione.
private func callHelper<T>(_ body: @escaping (UTMUSBHelperProtocol, @escaping (Result<T, Error>) -> Void) -> Void) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
let connection = self.makeConnection()
var didResume = false
let resumeOnce: (Result<T, Error>) -> Void = { result in
guard !didResume else { return }
didResume = true
connection.invalidate()
continuation.resume(with: result)
}
connection.interruptionHandler = {
resumeOnce(.failure(UTMUSBHelperError.xpcFailed(
NSLocalizedString("Connessione interrotta.", comment: "UTMUSBHelperError"))))
}
guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in
resumeOnce(.failure(UTMUSBHelperError.xpcFailed(error.localizedDescription)))
}) as? UTMUSBHelperProtocol else {
resumeOnce(.failure(UTMUSBHelperError.xpcFailed(
NSLocalizedString("Proxy XPC non valido.", comment: "UTMUSBHelperError"))))
return
}
body(proxy) { resumeOnce($0) }
}
}

func ping() async throws -> String {
try await callHelper { proxy, done in
proxy.ping { done(.success($0)) }
}
}

/// Collega il device alla VM in ascolto su `socketPath`. Il demone
/// smonta da sé gli eventuali volumi PRIMA della claim.
func attachDevice(vendorId: Int, productId: Int, socketPath: String) async throws {
try await callHelper { proxy, done in
proxy.attachDevice(vendorId: vendorId, productId: productId, socketPath: socketPath) { ok, err in
if ok { done(.success(())) }
else { done(.failure(UTMUSBHelperError.operationFailed(
err ?? NSLocalizedString("Errore sconosciuto.", comment: "UTMUSBHelperError")))) }
}
}
}

func detachDevice(vendorId: Int, productId: Int) async throws {
try await callHelper { proxy, done in
proxy.detachDevice(vendorId: vendorId, productId: productId) { ok, err in
if ok { done(.success(())) }
else { done(.failure(UTMUSBHelperError.operationFailed(
err ?? NSLocalizedString("Errore sconosciuto.", comment: "UTMUSBHelperError")))) }
}
}
}

func listAttached() async -> [String] {
(try? await callHelper { (proxy: UTMUSBHelperProtocol, done: @escaping (Result<[String], Error>) -> Void) in
proxy.listAttached { done(.success($0)) }
}) ?? []
}

/// Rete di sicurezza per la chiusura dell'app. No-op se il demone non è
/// mai stato installato: non ha senso mostrare il prompt di
/// installazione mentre si sta uscendo.
///
/// Nota: il demone stacca tutto anche da sé quando l'ultima connessione
/// XPC cade (§ main.swift), quindi questa è una cintura in più oltre
/// alle bretelle — copre il caso di uscita pulita senza attendere il
/// timeout di invalidazione.
func detachAllIfRegistered() async {
guard isRegistered else { return }
_ = try? await callHelper { (proxy: UTMUSBHelperProtocol, done: @escaping (Result<Void, Error>) -> Void) in
proxy.detachAll { _ in done(.success(())) }
}
}
}
#endif
68 changes: 68 additions & 0 deletions Services/UTMUSBHelperProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// XPC protocol shared between the app and UTMUSBHelper (§ UTMUSBHelper/, a
// privileged LaunchDaemon registered through SMAppService — macOS 13+).
// Compiled into BOTH targets: only the shape of the contract lives here,
// never the logic (that is in USBRedirService.swift, on the daemon side).
//
// ── Why a daemon at all ─────────────────────────────────────────────────
// On macOS `libusb_claim_interface()` fails with LIBUSB_ERROR_ACCESS for any
// device already bound to a kernel driver. Measured with a controlled A/B
// comparison (same device, same unmount, same binary — only the uid
// changes): as a normal user libusb refuses with "USB device capture
// requires either an entitlement (com.apple.vm.device-access) or root
// privilege"; as root it succeeds. That entitlement is restricted, so root
// it is — but only for this very small daemon.
//
// ── Why running QEMU as root is not the answer ──────────────────────────
// Because root does not bypass TCC: TCC protects by process identity, not by
// privilege. A QEMU started from a LaunchDaemon can no longer open the VM
// files. Here QEMU stays an ordinary user process and only talks to us over
// a socket: no TCC problem, no storage migration, the group container stays
// where it is.
//

import Foundation

/// Name of the Mach service the daemon listens on — the same value appears
/// in the launchd plist Label, in its MachServices entry, and here for the
/// app-side NSXPCConnection.
let kUTMUSBHelperMachServiceName = "com.utmapp.UTMUSBHelper"

@objc protocol UTMUSBHelperProtocol {
/// Diagnostic ping — checks that the daemon is actually reachable after
/// registration, before trusting any other call.
func ping(reply: @escaping (String) -> Void)

/// Redirects `vendorId:productId` to the VM listening on `socketPath`
/// (the `-chardev socket,server=on,wait=off` QEMU created at startup).
///
/// SAFETY INVARIANT: if the device is a storage unit, the daemon
/// unmounts its volumes with `diskutil unmountDisk` and proceeds ONLY if
/// the unmount succeeded. Never in parallel, never afterwards: claiming
/// a device while a volume is still mounted risks corruption.
func attachDevice(vendorId: Int,
productId: Int,
socketPath: String,
reply: @escaping (Bool, String?) -> Void)

/// Gives the device back to macOS: releases the interfaces, **resets it
/// to force re-enumeration** and remounts the volumes.
///
/// The reset is not a detail: verified live that without it macOS does
/// not probe the device again, and it stays enumerated on the USB bus
/// with no block device — `diskutil` hangs and the only remedy left to
/// the user is unplugging the cable.
func detachDevice(vendorId: Int,
productId: Int,
reply: @escaping (Bool, String?) -> Void)

/// Detach everything. A safety net called when the app quits; the daemon
/// also runs it on its own when the client's XPC connection is
/// invalidated (app quit or crashed), because it owns the devices and
/// outlives the client.
func detachAll(reply: @escaping (Bool) -> Void)

/// Currently redirected devices, as "vvvv:pppp" hex strings — the UI
/// uses it to rebuild its state after an app restart instead of guessing.
func listAttached(reply: @escaping ([String]) -> Void)
}
38 changes: 38 additions & 0 deletions UTMUSBHelper/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.utmapp.UTMUSBHelper</string>
<key>CFBundleName</key>
<string>UTMUSBHelper</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleDisplayName</key>
<string>UTM — Redirezione USB</string>
<!-- Testo mostrato dal sistema nella finestra di richiesta di accesso.
Serve perché la claim di un'interfaccia USB di un dispositivo di
ARCHIVIAZIONE passa dal servizio TCC "Volumi rimovibili": senza
questa stringa il prompt non ha nulla da mostrare all'utente.
§ usbredir_bridge.c per la catena completa del controllo. -->
<key>NSRemovableVolumesUsageDescription</key>
<string>UTM deve prendere possesso del dispositivo USB per collegarlo direttamente alla macchina virtuale. Il dispositivo viene smontato da macOS per la durata del collegamento e restituito al distacco.</string>
<!-- Chi può parlare con questo demone. Il requisito di firma va
compilato DENTRO il binario (CREATE_INFOPLIST_SECTION_IN_BINARY),
non lasciato in un file accanto: è ciò che impedisce a un
eseguibile qualunque di farsi passare per UTM e chiedere
al demone di reclamare i tuoi dischi.

Solo `identifier`, senza `anchor apple generic`: la build è
firmata con un certificato di sviluppo personale, non
distribuita, quindi non esiste un Team ID stabile su cui
ancorarsi. Se un giorno la si distribuisse, qui andrebbe
aggiunto l'anchor col Team ID reale. -->
<key>SMAuthorizedClients</key>
<array>
<string>identifier "com.utmapp.UTM"</string>
</array>
</dict>
</plist>
78 changes: 78 additions & 0 deletions UTMUSBHelper/USBDiskLocator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// From `vid:pid` to the BSD disks that device exposes.
//
// Used to unmount EXACTLY the volumes of the device we are about to claim
// — never by guessing from a name or from a position in `diskutil list`,
// which changes on every re-enumeration (after a reset the same SSD can
// come back with a different identifier).
//
// If the device is not a storage unit (keyboard, dongle, webcam…) the list
// is simply empty and there is nothing to unmount: that is the normal case,
// not an error.
//

import Foundation
import IOKit

enum USBDiskLocator {
/// Nomi BSD dei dischi INTERI (es. "disk4", mai "disk4s1") esposti dal
/// device USB indicato. Vuoto se non è un dispositivo di archiviazione.
static func wholeDisks(forVendorId vid: Int, productId pid: Int) -> [String] {
guard let matching = IOServiceMatching("IOUSBHostDevice") as NSMutableDictionary? else {
return []
}
matching["idVendor"] = NSNumber(value: vid)
matching["idProduct"] = NSNumber(value: pid)

var iterator: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault,
matching as CFDictionary,
&iterator) == KERN_SUCCESS else {
return []
}
defer { IOObjectRelease(iterator) }

var disks: [String] = []
while case let device = IOIteratorNext(iterator), device != 0 {
defer { IOObjectRelease(device) }
disks.append(contentsOf: wholeDisksUnder(device))
}
// Lo stesso vid:pid potrebbe teoricamente comparire su più unità;
// deduplica preservando l'ordine.
var seen = Set<String>()
return disks.filter { seen.insert($0).inserted }
}

/// Scende ricorsivamente nell'albero IORegistry sotto un device USB in
/// cerca di nodi IOMedia che rappresentino un disco intero.
private static func wholeDisksUnder(_ device: io_object_t) -> [String] {
var iterator: io_iterator_t = 0
guard IORegistryEntryCreateIterator(device,
kIOServicePlane,
IOOptionBits(kIORegistryIterateRecursively),
&iterator) == KERN_SUCCESS else {
return []
}
defer { IOObjectRelease(iterator) }

var result: [String] = []
while case let child = IOIteratorNext(iterator), child != 0 {
defer { IOObjectRelease(child) }
guard IOObjectConformsTo(child, "IOMedia") != 0 else { continue }

// Solo il disco intero: smontare quello smonta tutte le sue
// partizioni in un colpo solo (`diskutil unmountDisk`).
let isWhole = (IORegistryEntryCreateCFProperty(child, "Whole" as CFString,
kCFAllocatorDefault, 0)?
.takeRetainedValue() as? NSNumber)?.boolValue ?? false
guard isWhole else { continue }

if let bsd = (IORegistryEntryCreateCFProperty(child, "BSD Name" as CFString,
kCFAllocatorDefault, 0)?
.takeRetainedValue() as? String) {
result.append(bsd)
}
}
return result
}
}
Loading