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
6 changes: 5 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -479,23 +479,27 @@ jobs:
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo $IOS_REMOTE_PROFILE_DATA | base64 --decode -o ~/Library/MobileDevice/Provisioning\ Profiles/$IOS_REMOTE_PROFILE_UUID.provisionprofile
echo $IOS_SE_PROFILE_DATA | base64 --decode -o ~/Library/MobileDevice/Provisioning\ Profiles/$IOS_SE_PROFILE_UUID.provisionprofile
echo $IOS_SE_HELPER_PROFILE_DATA | base64 --decode -o ~/Library/MobileDevice/Provisioning\ Profiles/$IOS_SE_HELPER_PROFILE_UUID.provisionprofile
echo $LAUNCHER_PROFILE_DATA | base64 --decode -o ~/Library/MobileDevice/Provisioning\ Profiles/$LAUNCHER_PROFILE_UUID.provisionprofile
env:
IOS_REMOTE_PROFILE_DATA: ${{ vars.IOS_REMOTE_PROFILE_DATA }}
IOS_REMOTE_PROFILE_UUID: ${{ vars.IOS_REMOTE_PROFILE_UUID }}
IOS_SE_PROFILE_DATA: ${{ vars.IOS_SE_PROFILE_DATA }}
IOS_SE_PROFILE_UUID: ${{ vars.IOS_SE_PROFILE_UUID }}
IOS_SE_HELPER_PROFILE_DATA: ${{ vars.IOS_SE_HELPER_PROFILE_DATA }}
IOS_SE_HELPER_PROFILE_UUID: ${{ vars.IOS_SE_HELPER_PROFILE_UUID }}
- name: Download Artifact
uses: actions/download-artifact@v4
with:
name: UTM-${{ matrix.configuration.scheme }}-${{ matrix.configuration.platform }}-arm64
- name: Package for App Store
run: |
tar -xf UTM.xcarchive.tgz
./scripts/package.sh ${{ matrix.configuration.mode }} UTM.xcarchive . "$SIGNING_TEAM_ID" "$PROFILE_UUID" app-store
./scripts/package.sh ${{ matrix.configuration.mode }} UTM.xcarchive . "$SIGNING_TEAM_ID" "$PROFILE_UUID" app-store "$HELPER_PROFILE_UUID"
env:
SIGNING_TEAM_ID: ${{ vars.SIGNING_TEAM_ID }}
PROFILE_UUID: ${{ matrix.configuration.scheme == 'iOS-Remote' && vars.IOS_REMOTE_PROFILE_UUID || vars.IOS_SE_PROFILE_UUID }}
HELPER_PROFILE_UUID: ${{ matrix.configuration.scheme == 'iOS-SE' && vars.IOS_SE_HELPER_PROFILE_UUID || '' }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
Expand Down
11 changes: 8 additions & 3 deletions CodeSigning.xcconfig.sample
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ DEVELOPMENT_TEAM = XYZ0123456
// Prefix of unique bundle IDs registered to you in Apple Developer Portal.
// You need to register:
// - com.myuniquename.UTM
// - com.myuniquename.QEMUHelper
// - com.myuniquename.QEMULauncher
// - com.myuniquename.UTM.iOSHelper (iOS)
// - com.myuniquename.UTM-SE.iOSHelper (iOS)
// - com.myuniquename.QEMUHelper (macOS)
// - com.myuniquename.QEMULauncher (macOS)
PRODUCT_BUNDLE_PREFIX = com.myuniquename

// Set to YES if you have a valid paid Apple Developer account
Expand Down Expand Up @@ -55,7 +57,10 @@ PROVISIONING_PROFILE_SPECIFIER_LAUNCHER = 00000000-1111-2222-3333-555555555555
// Otherwise, change it to 'Manual' and fill in the profile specifiers below
CODE_SIGN_STYLE_IOS = Automatic

// If using manual iOS signing, fill the profile specifiers for each app
// If using manual iOS signing, fill the profile specifiers for each app and
// for the helper extension embedded in UTM and UTM SE
PROVISIONING_PROFILE_SPECIFIER_IOS =
PROVISIONING_PROFILE_SPECIFIER_SE =
PROVISIONING_PROFILE_SPECIFIER_REMOTE =
PROVISIONING_PROFILE_SPECIFIER_IOS_HELPER =
PROVISIONING_PROFILE_SPECIFIER_SE_HELPER =
9 changes: 6 additions & 3 deletions Configuration/UTMConfigurationDrive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ extension UTMConfigurationDrive {
}
let fileManager = FileManager.default
if let imageURL = imageURL {
#if os(macOS)
let newURL = try await UTMQemuConfiguration.copyItemIfChanged(from: imageURL, to: dataURL, customCopy: isRawImage ? nil : convertQcow2Image)
#else
#if WITH_REMOTE
let newURL = try await UTMQemuConfiguration.copyItemIfChanged(from: imageURL, to: dataURL)
#else
let customCopy = isRawImage || !UTMQemuImage.isSupported ? nil : convertQcow2Image
let newURL = try await UTMQemuConfiguration.copyItemIfChanged(from: imageURL, to: dataURL, customCopy: customCopy)
#endif
self.imageName = newURL.lastPathComponent
self.imageURL = newURL
Expand Down Expand Up @@ -136,7 +137,9 @@ extension UTMConfigurationDrive {
try UTMAppleDiskImage.createASIF(at: newURL, sizeMib: sizeMib)
}.value
}
#endif

#if !WITH_REMOTE
private func convertQcow2Image(at sourceURL: URL, to destFolderURL: URL) async throws -> URL {
let destQcow2 = UTMData.newImage(from: sourceURL,
to: destFolderURL,
Expand Down
4 changes: 4 additions & 0 deletions Documentation/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ On macOS, spawning new processes is permitted but due to App Sandbox security re

The XPC helper spawns `QEMULauncher` with an inherited sandbox. This means it can access files that are accessible to the helper XPC. The launcher process is what runs QEMU. When a new file is opened (for example a new disk image is mounted), the main application will pass a bookmark to the helper XPC where it will call `-startAccessingSecurityScopedResource` which also applies to the child process (`QEMULauncher`). This way, QEMU does not have to have any knowledge of the App Sandbox.

#### iOS Helper

On iOS 26 and later, tools such as `qemu-img` run out of process in `iOSHelper`, an ExtensionKit extension embedded in the app (`iOSHelper-SE` for UTM SE, as an extension's bundle identifier must start with its host's). It implements the same `QEMUHelperProtocol` as `QEMUHelper`, but loads the tool into its own process like the in-thread QEMU does, so each helper process runs one tool and the app launches a new one for the next. `UTMHelperProcess` launches the helper through `AppExtensionProcess` and hands its connection to `UTMProcess`, which otherwise works as on macOS: the helper has no access to the app's container, so files are passed as bookmarks, whose implicit security scope lets the process that resolves them access the file. QEMU itself still runs in the app process on iOS.

### UTMConfiguration

VM configuration is stored in a PLIST format. This PLIST maps to either a `UTMQemuConfiguration` or `UTMAppleConfiguration` structure which stores the underlying configuration data in a `Codable` interface for easy serialization.
Expand Down
4 changes: 3 additions & 1 deletion Documentation/Release.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ Below is a summary of all the variables and secrets used by GitHub Actions in th
|`IOS_REMOTE_PROFILE_UUID` |UUID of provisioning profile above |
|`IOS_SE_PROFILE_DATA` |Base64 encoded provisioning profile of iOS SE for App Store submission |
|`IOS_SE_PROFILE_UUID` |UUID of provisioning profile above |
|`IOS_SE_HELPER_PROFILE_DATA` |Base64 encoded provisioning profile of the iOS SE helper extension for App Store |
|`IOS_SE_HELPER_PROFILE_UUID` |UUID of provisioning profile above |
|`IS_SELF_HOSTED_RUNNER` |Set to `true` to use a self hosted macOS runner set up by the owner |

### Signing for release
Expand All @@ -172,7 +174,7 @@ The following certificates (and associated private keys) must be exported from K

Give a password when prompted and save it to the repository secret `SIGNING_CERTIFICATE_PASSWORD`. Then, in Terminal, convert the PKCS#12 file to Base64 and copy it: `cat Certificates.p12 | base64 | pbcopy` and paste it to `SIGNING_CERTIFICATE_P12_DATA`.

Next you need to get each provisioning profile {3 profiles for macOS} X {1 for Developer ID, 1 for Mac App Store}. Save each UUID of the profile as `*_PROFILE_UUID` and the Base64 encoded data from `cat name.provisionprofile | base64 | pbcopy` as `*_PROFILE_DATA`.
Next you need to get each provisioning profile {3 profiles for macOS} X {1 for Developer ID, 1 for Mac App Store} plus the App Store profiles for iOS: UTM SE, its helper extension (`com.utmapp.UTM-SE.iOSHelper`) and UTM Remote. Save each UUID of the profile as `*_PROFILE_UUID` and the Base64 encoded data from `cat name.provisionprofile | base64 | pbcopy` as `*_PROFILE_DATA`.

### AltStore Repository

Expand Down
8 changes: 6 additions & 2 deletions Documentation/iOSDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ Artifacts built with `build_utm.sh` (includes GitHub Actions artifacts) must be
For a user friendly option, you can use [iOS App Signer][3] to re-sign the `.xcarchive`. Advanced users can use the package.sh script:

```
./scripts/package.sh signedipa /path/to/UTM.xcarchive /path/to/output TEAM_ID PROFILE_UUID
./scripts/package.sh ipa-signed /path/to/UTM.xcarchive /path/to/output TEAM_ID PROFILE_UUID development HELPER_PROFILE_UUID
```

This builds `UTM.ipa` in `/path/to/output` which can be installed by Xcode, iTunes, or AirDrop. Note that you need a "Development" signing certificate and NOT a "Distribution" certificate. This is because UTM requires a provisioning profile with the `get-task-allow` entitlement which Apple only grants for Development signing.
This builds `UTM.ipa` in `/path/to/output` which can be installed by Xcode, iTunes, or AirDrop. Note that you need a "Development" signing certificate and NOT a "Distribution" certificate. This is because UTM requires a provisioning profile with the `get-task-allow` entitlement which Apple only grants for Development signing. `HELPER_PROFILE_UUID` is a second profile for the helper extension embedded in the app (`com.utmapp.UTM.iOSHelper`, or `com.utmapp.UTM-SE.iOSHelper` for `ipa-se-signed`), which runs QEMU tools such as `qemu-img` out of process on iOS 26 and later.

#### Unsigned IPA

Expand Down Expand Up @@ -95,6 +95,10 @@ If you have a free Apple Developer account, you need to generate a new signing c

Set `DEVELOPER_ACCOUNT_PAID = YES` if you used a paid Apple Developer account in order to automatically request the increased memory limit entitlement from Apple.

### Helper extension

On iOS 26 and later, tools such as `qemu-img` (used for disk snapshots, resizing and conversion) run in an ExtensionKit extension embedded in the app. When Address Sanitizer is enabled in the scheme's diagnostics, the extension fails to start on a device and these operations report that the helper quit unexpectedly; turn the sanitizer off to test them.

### Tethered Launch

For JIT to work on the latest version of iOS, it must be launched through the debugger. You can do it from Xcode (and detach the debugger after launching) or you can follow [these instructions](TetheredLaunch.md) for an easier way.
Expand Down
106 changes: 60 additions & 46 deletions Platform/Shared/VMConfigDriveDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,11 @@ struct VMConfigDriveDetailsView: View {
private enum ConfirmItem: Identifiable {
case reclaim(URL)
case compress(URL)
case resize(URL)

var id: Int {
switch self {
case .reclaim(_): return 1
case .compress(_): return 2
case .resize(_): return 3
}
}
}
Expand All @@ -41,7 +39,6 @@ struct VMConfigDriveDetailsView: View {

@State private var confirmAlert: ConfirmItem?
@State private var isResizePopoverShown: Bool = false
@State private var proposedSizeMib: Int = 0

var body: some View {
Form {
Expand Down Expand Up @@ -103,50 +100,58 @@ struct VMConfigDriveDetailsView: View {
DefaultTextField("Size", text: .constant(ByteCountFormatter.string(fromByteCount: Int64(config.sizeMib) * bytesInMib, countStyle: .binary))).disabled(true)
}

#if os(macOS)
HStack {
if let imageUrl = config.imageURL, FileManager.default.fileExists(atPath: imageUrl.path) {
Button {
confirmAlert = .reclaim(imageUrl)
} label: {
Label("Reclaim Space", systemImage: "arrow.3.trianglepath")
}.help("Reclaim disk space by re-converting the disk image.")

Button {
confirmAlert = .compress(imageUrl)
} label: {
Label("Compress", systemImage: "arrowtriangle.right.and.line.vertical.and.arrowtriangle.left")
}.help("Compress by re-converting the disk image and compressing the data.")

Button {
isResizePopoverShown.toggle()
} label: {
Label("Resize…", systemImage: "arrowtriangle.left.and.line.vertical.and.arrowtriangle.right")
}.help("Increase the size of the disk image.")
.popover(isPresented: $isResizePopoverShown) {
ResizePopoverView(imageURL: imageUrl, proposedSizeMib: $proposedSizeMib) {
confirmAlert = .resize(imageUrl)
}.padding()
.frame(minHeight: 100)
}
}
}.alert(item: $confirmAlert) { item in
switch item {
case .reclaim(let imageURL):
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space? Note this will require enough temporary space to perform the conversion. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: false) }, secondaryButton: .cancel())
case .compress(let imageURL):
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space and apply compression? Note this will require enough temporary space to perform the conversion. Compression only applies to existing data and new data will still be written uncompressed. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: true) }, secondaryButton: .cancel())
case .resize(let imageURL):
return Alert(title: Text("Resizing is experimental and could result in data loss. You are strongly encouraged to back-up this VM before proceeding. Would you like to resize to \(proposedSizeMib / mibInGib) GiB?"), primaryButton: .destructive(Text("Resize")) {
resizeDrive(for: imageURL, sizeInMib: proposedSizeMib)
}, secondaryButton: .cancel())
#if !WITH_REMOTE
if UTMQemuImage.isSupported, let imageUrl = config.imageURL, FileManager.default.fileExists(atPath: imageUrl.path) {
#if os(macOS)
HStack {
imageTools(for: imageUrl)
}
#else
imageTools(for: imageUrl)
#endif
}
#endif
}
#if !WITH_REMOTE
.alert(item: $confirmAlert) { item in
switch item {
case .reclaim(let imageURL):
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space? Note this will require enough temporary space to perform the conversion. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: false) }, secondaryButton: .cancel())
case .compress(let imageURL):
return Alert(title: Text("Would you like to re-convert this disk image to reclaim unused space and apply compression? Note this will require enough temporary space to perform the conversion. Compression only applies to existing data and new data will still be written uncompressed. You are strongly encouraged to back-up this VM before proceeding."), primaryButton: .destructive(Text("Reclaim")) { reclaimSpace(for: imageURL, withCompression: true) }, secondaryButton: .cancel())
}
}
#endif
}

#if os(macOS)
#if !WITH_REMOTE
@ViewBuilder
private func imageTools(for imageUrl: URL) -> some View {
Button {
confirmAlert = .reclaim(imageUrl)
} label: {
Label("Reclaim Space", systemImage: "arrow.3.trianglepath")
}.help("Reclaim disk space by re-converting the disk image.")

Button {
confirmAlert = .compress(imageUrl)
} label: {
Label("Compress", systemImage: "arrowtriangle.right.and.line.vertical.and.arrowtriangle.left")
}.help("Compress by re-converting the disk image and compressing the data.")

Button {
isResizePopoverShown.toggle()
} label: {
Label("Resize…", systemImage: "arrowtriangle.left.and.line.vertical.and.arrowtriangle.right")
}.help("Increase the size of the disk image.")
.popover(isPresented: $isResizePopoverShown) {
ResizePopoverView(imageURL: imageUrl) { sizeInMib in
resizeDrive(for: imageUrl, sizeInMib: sizeInMib)
}.padding()
.frame(minHeight: 100)
}
}

private func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool) {
data.busyWorkAsync {
try await data.reclaimSpace(for: driveUrl, withCompression: isCompressed)
Expand All @@ -161,14 +166,17 @@ struct VMConfigDriveDetailsView: View {
#endif
}

#if os(macOS)
#if !WITH_REMOTE
/// Asks for the new size and confirms it here, as an alert on the form under this view
/// is dropped on iOS when it is set while the sheet closes.
private struct ResizePopoverView: View {
let imageURL: URL
@Binding var proposedSizeMib: Int
let onConfirm: () -> Void
let onConfirm: (Int) -> Void
@EnvironmentObject private var data: UTMData

@State private var currentSize: Int64?
@State private var proposedSizeMib: Int = 0
@State private var isConfirming: Bool = false

@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>

Expand All @@ -192,14 +200,20 @@ private struct ResizePopoverView: View {
SizeTextField($proposedSizeMib, minSizeMib: minSizeMib)
Button("Resize") {
if proposedSizeMib > minSizeMib {
onConfirm()
isConfirming = true
} else {
presentationMode.wrappedValue.dismiss()
}
presentationMode.wrappedValue.dismiss()
}
}
} else {
ProgressView("Calculating current size...")
}
}.alert(isPresented: $isConfirming) {
Alert(title: Text("Resizing is experimental and could result in data loss. You are strongly encouraged to back-up this VM before proceeding. Would you like to resize to \(proposedSizeMib / mibInGib) GiB?"), primaryButton: .destructive(Text("Resize")) {
onConfirm(proposedSizeMib)
presentationMode.wrappedValue.dismiss()
}, secondaryButton: .cancel())
}.onAppear {
Task { @MainActor in
currentSize = await data.qcow2DriveSize(for: imageURL)
Expand Down
4 changes: 3 additions & 1 deletion Platform/UTMData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,7 @@ enum AlertItem: Identifiable {

// MARK: - Reclaim space

#if os(macOS)
#if !WITH_REMOTE
/// Reclaim empty space in a file by (re)-converting it to QCOW2
///
/// This will overwrite driveUrl with the converted file on success!
Expand Down Expand Up @@ -943,7 +943,9 @@ enum AlertItem: Identifiable {
let bytesinMib = 1048576
try await UTMQemuImage.resize(image: driveUrl, size: UInt64(sizeInMib * bytesinMib))
}
#endif

#if os(macOS)
@available(macOS 14, *)
func appleDriveInfo(for driveUrl: URL) -> (format: String?, size: Int64?) {
guard let info = try? UTMAppleDiskImage.info(for: driveUrl) else {
Expand Down
Loading
Loading