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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ doctor:
@printf 'Toolchain ready: %s (%s)\n' "$$(sw_vers -productVersion)" "$$(uname -m)"

test:
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-network-identity.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-libslirp-icmp.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-pinch.py"
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-virtio-pinch.py"
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,24 @@ bridged launches do not request your password. QEMU continues to run as your
user. **Remove Networking Helper** unregisters the service when it is no longer
needed. Shut down any bridged VM before repairing or removing the helper.

Persistent VMs keep a stable, randomly generated bridged MAC address across app
updates, disk replacement, resizing, resets, and moves of the complete VM data
folder. Existing saved addresses are retained when upgrading from older builds.
The Networking sheet displays the address after the first bridged launch;
**Copy MAC** makes it available for a DHCP reservation. **Generate new MAC…**
shows a proposed address and requires confirmation while the VM is stopped.
This action saves immediately; DHCP reservations may need updating. Cancelling
the confirmation leaves the existing identity unchanged.

A copy of the complete VM data folder includes its network identity. To run a
copy as a separate VM, generate a new MAC before running both copies. Move or
restore the complete data folder to retain the identity; importing only a disk
into a new workspace does not transfer its network identity. Ephemeral bridged
VMs receive a fresh address on each launch. Damaged identity records produce an
error instead of silently changing the MAC. Migration and regeneration retain
the preceding record as `network-identities/current.previous.json` in the VM
data folder; restore a known-good record only with the VM stopped.

For repeated local development builds, use a consistent Apple Development
signing identity (the `DEVELOPMENT_SIGN_IDENTITY` option above). Ad-hoc-signed
helper registrations are not reliable across rebuilds on the tested macOS
Expand Down
68 changes: 66 additions & 2 deletions macos/Sources/OmarchyVMHelper/NetworkEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ final class NetworkEditor: NSObject {
private let interface = NSPopUpButton()
private let explanation = NSTextField(wrappingLabelWithString: "")
private let ssh = NSButton(checkboxWithTitle: "Allow SSH connections from the LAN", target: nil, action: nil)
private let macAddress = NSTextField(wrappingLabelWithString: "")
private let macHelp = NSTextField(wrappingLabelWithString: "This address stays the same when you update or move this VM. For a copied VM, generate a new address before running both copies.")
private let copyMAC = NSButton(title: "Copy MAC", target: nil, action: nil)
private let regenerateMAC = NSButton(title: "Generate new MAC…", target: nil, action: nil)
private let macControls = NSStackView()
private let identity: VMNetworkIdentityAccess
private var currentMAC = ""
private var canReplaceMAC = false
private let detail = NSTextField(wrappingLabelWithString: "")
private let serviceStatus = NSTextField(wrappingLabelWithString: "")
private let setup = NSButton()
Expand All @@ -21,7 +29,9 @@ final class NetworkEditor: NSObject {
private let didClose: () -> Void

init(preferences: VMNetworkPreferences, interfaces: [VMBridgeInterface],
identity: VMNetworkIdentityAccess = .unavailable,
save: @escaping (VMNetworkPreferences) -> String?, didClose: @escaping () -> Void) {
self.identity = identity
self.savedPreferences = preferences
self.interfaces = interfaces
self.save = save
Expand Down Expand Up @@ -54,7 +64,17 @@ final class NetworkEditor: NSObject {
controls.addArrangedSubview(setup)
controls.addArrangedSubview(remove)
controls.spacing = 8
let rows: [NSView] = [mode, interface, explanation, ssh, detail, serviceStatus, controls]
macAddress.setAccessibilityLabel("Bridged MAC address")
macAddress.isSelectable = true
macHelp.font = .systemFont(ofSize: 12)
macHelp.textColor = .secondaryLabelColor
copyMAC.target = self; copyMAC.action = #selector(copyAddress)
regenerateMAC.target = self; regenerateMAC.action = #selector(regenerateAddress)
macControls.addArrangedSubview(copyMAC)
macControls.addArrangedSubview(regenerateMAC)
macControls.spacing = 8
loadIdentity()
let rows: [NSView] = [mode, interface, explanation, ssh, macAddress, macHelp, macControls, detail, serviceStatus, controls]
for row in rows { stack.addArrangedSubview(row) }
stack.orientation = .vertical
stack.alignment = .leading
Expand All @@ -72,7 +92,7 @@ final class NetworkEditor: NSObject {
mode.isEnabled = !serviceBusy
alert.buttons.last?.isEnabled = !serviceBusy
let bridged = mode.indexOfSelectedItem == 1
for view in [interface, explanation, ssh, serviceStatus, controls] as [NSView] {
for view in [interface, explanation, ssh, macAddress, macHelp, macControls, serviceStatus, controls] as [NSView] {
view.isHidden = !bridged
}
let valid = interfaces.indices.contains(interface.indexOfSelectedItem)
Expand All @@ -83,6 +103,8 @@ final class NetworkEditor: NSObject {
explanation.stringValue = compatibilityRequired
? "Wi-Fi bridging on this Mac temporarily adjusts DHCP handling for all bridged VMs, including other virtualization apps. The previous setting is restored when Omarchy stops. Saving this choice enables that handling automatically."
: "The networking helper is approved once through macOS. Subsequent bridged launches do not ask for your password."
copyMAC.isEnabled = !serviceBusy && !currentMAC.isEmpty
regenerateMAC.isEnabled = !serviceBusy && !currentMAC.isEmpty && canReplaceMAC
ssh.isEnabled = bridged && !serviceBusy
alert.buttons.first?.isEnabled = !serviceBusy && (!bridged || valid || !savedPreferences.interface.isEmpty)
detail.stringValue = bridged
Expand All @@ -94,6 +116,48 @@ final class NetworkEditor: NSObject {
alert.layout()
}

private func loadIdentity() {
do {
currentMAC = try identity.read()
canReplaceMAC = !currentMAC.isEmpty && identity.canReplace()
regenerateMAC.toolTip = canReplaceMAC ? nil : "Shut down the VM before changing its MAC address."
macAddress.stringValue = currentMAC.isEmpty
? "MAC address: assigned on the first bridged launch."
: "MAC address: \(currentMAC)"
} catch {
currentMAC = ""
macAddress.stringValue = error.localizedDescription
}
}

@objc private func copyAddress() {
guard !currentMAC.isEmpty else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(currentMAC, forType: .string)
}

@objc private func regenerateAddress() {
guard !serviceBusy, !currentMAC.isEmpty else { return }
var proposed = VMNetworkIdentityAccess.proposedMAC()
while proposed == currentMAC { proposed = VMNetworkIdentityAccess.proposedMAC() }
let confirmation = NSAlert()
confirmation.messageText = "Generate a new MAC address?"
confirmation.informativeText = "Current: \(currentMAC)\nNew: \(proposed)\n\nThis VM must be shut down. DHCP reservations may need updating. This change is saved immediately, independently of the Networking Save button."
confirmation.addButton(withTitle: "Change MAC Address")
confirmation.addButton(withTitle: "Cancel")
guard confirmation.runModal() == .alertFirstButtonReturn else { return }
do {
_ = try identity.replace(currentMAC, proposed)
} catch {
let problem = NSAlert()
problem.messageText = "MAC address could not be changed"
problem.informativeText = error.localizedDescription
problem.runModal()
}
loadIdentity()
update()
}

private func serviceAction(success: String? = nil, _ action: @escaping @MainActor () async throws -> Void) {
guard !serviceBusy else { return }
serviceBusy = true
Expand Down
37 changes: 37 additions & 0 deletions macos/Sources/OmarchyVMHelper/NetworkIdentity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

struct VMNetworkIdentityAccess {
var read: () throws -> String
var canReplace: () -> Bool = { false }
var replace: (_ expected: String, _ proposed: String) throws -> String

static let unavailable = Self(
read: { "" },
replace: { _, _ in throw HelperError.io("Start this VM once before changing its MAC address.") }
)

static func proposedMAC() -> String {
"02:" + (0..<5).map { _ in String(format: "%02x", UInt8.random(in: .min ... .max)) }.joined(separator: ":")
}

static func operation(_ arguments: [String], root: URL, resources: URL) throws -> String {
let process = Process()
let output = Pipe()
let errors = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["python3", resources.appendingPathComponent("scripts/network-identity.py").path,
arguments[0], root.path, "current"] + arguments.dropFirst()
process.standardOutput = output
process.standardError = errors
try process.run()
process.waitUntilExit()
let result = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
let error = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard process.terminationStatus == 0 else {
throw HelperError.io(error.isEmpty ? "The VM network identity could not be read." : error)
}
return result
}
}
5 changes: 4 additions & 1 deletion macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
private let saveResources: (VMResources) -> Void
private let networkPreferences: () -> VMNetworkPreferences
private let saveNetworkPreferences: (VMNetworkPreferences) -> String?
private let networkIdentity: VMNetworkIdentityAccess
private var networkEditor: NetworkEditor?
private let immersiveMode: () -> Bool
private let setImmersiveMode: (Bool) -> Void
Expand Down Expand Up @@ -255,6 +256,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
saveResources: @escaping (VMResources) -> Void = { _ in },
networkPreferences: @escaping () -> VMNetworkPreferences = { VMNetworkPreferences() },
saveNetworkPreferences: @escaping (VMNetworkPreferences) -> String? = { _ in nil },
networkIdentity: VMNetworkIdentityAccess = .unavailable,
immersiveMode: @escaping () -> Bool = { true },
setImmersiveMode: @escaping (Bool) -> Void = { _ in },
launch: @escaping () -> Void
Expand Down Expand Up @@ -284,6 +286,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
self.saveResources = saveResources
self.networkPreferences = networkPreferences
self.saveNetworkPreferences = saveNetworkPreferences
self.networkIdentity = networkIdentity
self.immersiveMode = immersiveMode
self.setImmersiveMode = setImmersiveMode
self.launch = launch
Expand Down Expand Up @@ -1353,7 +1356,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
guard !launchInProgress, !resetInProgress, networkEditor == nil else { return }
permissionWindowRestorer.cancel()
let editor = NetworkEditor(preferences: networkPreferences(), interfaces: VMBridgeInterfaces.available(),
save: saveNetworkPreferences, didClose: { [weak self] in
identity: networkIdentity, save: saveNetworkPreferences, didClose: { [weak self] in
self?.networkEditor = nil
self?.render()
})
Expand Down
21 changes: 21 additions & 0 deletions macos/Sources/OmarchyVMHelper/VMApplicationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,16 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
do { try self?.networkStore.save(preferences); return nil }
catch { return error.localizedDescription }
},
networkIdentity: VMNetworkIdentityAccess(
read: { [weak self] in try self?.networkIdentityOperation(["show"]) ?? "" },
canReplace: { [weak self] in
guard let self else { return false }
return (try? self.networkIdentityOperation(["check"])) != nil
},
replace: { [weak self] expected, proposed in
guard let self else { throw HelperError.io("The VM controller is unavailable.") }
return try self.networkIdentityOperation(["replace", expected, proposed])
}),
immersiveMode: { [weak self] in
self?.fullscreenPreferenceStore.load().isImmersive ?? true
},
Expand Down Expand Up @@ -406,6 +416,17 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
let storageUnavailableReason: String?
}

private func networkIdentityOperation(_ arguments: [String]) throws -> String {
let context = childLaunchContext()
if let error = context.storageUnavailableReason { throw HelperError.io(error) }
guard let root = QEMUGPUStorageSpaceEstimate.storageRootURL(
environment: context.environment, preference: storageLocationStore.load()),
let resources = Bundle.main.resourceURL else {
throw HelperError.io("The VM data folder is unavailable.")
}
return try VMNetworkIdentityAccess.operation(arguments, root: root, resources: resources)
}

private func resolvedNetworkPreferences() -> VMNetworkPreferences {
var preferences = networkStore.load()
if preferences.mode == .bridged,
Expand Down
2 changes: 1 addition & 1 deletion macos/Tests/qemu-networking.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ first=$(qemu_network_mac)
cp "$QEMU_SELECTED_DISK" "$QEMU_SELECTED_DISK.new"
mv "$QEMU_SELECTED_DISK.new" "$QEMU_SELECTED_DISK"
second=$(qemu_network_mac)
[[ $first != "$second" ]] || fail 'replacement disk reused identity'
[[ $first == "$second" ]] || fail 'replacement disk changed identity'
[[ $second == "$(qemu_network_mac)" ]] || fail 'replacement identity not retained'
chmod 644 "$test_root/network-identities/current.json"
if qemu_network_mac >/dev/null 2>&1; then fail 'unsafe record permissions accepted'; fi
Expand Down
Loading
Loading