497 lines
17 KiB
Swift
497 lines
17 KiB
Swift
//
|
|
// Copyright © 2022 osy. All rights reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
import Foundation
|
|
import Combine
|
|
|
|
@objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
|
|
/// Empty registry entry used only as a workaround for object initialization
|
|
static let empty = UTMRegistryEntry(uuid: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, name: "", path: "")
|
|
|
|
@Published private var _name: String
|
|
|
|
@Published private var _package: File
|
|
|
|
private(set) var uuid: UUID
|
|
|
|
@Published private var _isSuspended: Bool
|
|
|
|
@Published private var _externalDrives: [String: File]
|
|
|
|
@Published private var _sharedDirectories: [File]
|
|
|
|
@Published private var _windowSettings: [Int: Window]
|
|
|
|
@Published private var _terminalSettings: [Int: Terminal]
|
|
|
|
@Published private var _hasMigratedConfig: Bool
|
|
|
|
@Published private var _macRecoveryIpsw: File?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name = "Name"
|
|
case package = "Package"
|
|
case uuid = "UUID"
|
|
case isSuspended = "Suspended"
|
|
case externalDrives = "ExternalDrives"
|
|
case sharedDirectories = "SharedDirectories"
|
|
case windowSettings = "WindowSettings"
|
|
case terminalSettings = "TerminalSettings"
|
|
case hasMigratedConfig = "MigratedConfig"
|
|
case macRecoveryIpsw = "MacRecoveryIpsw"
|
|
}
|
|
|
|
init(uuid: UUID, name: String, path: String, bookmark: Data? = nil) {
|
|
_name = name
|
|
let package: File?
|
|
if let bookmark = bookmark {
|
|
package = try? File(path: path, bookmark: bookmark)
|
|
} else {
|
|
package = nil
|
|
}
|
|
_package = package ?? File(dummyFromPath: path)
|
|
self.uuid = uuid
|
|
_isSuspended = false
|
|
_externalDrives = [:]
|
|
_sharedDirectories = []
|
|
_windowSettings = [:]
|
|
_terminalSettings = [:]
|
|
_hasMigratedConfig = false
|
|
}
|
|
|
|
convenience init(newFrom vm: any UTMVirtualMachine) {
|
|
self.init(uuid: vm.id, name: vm.name, path: vm.pathUrl.path)
|
|
if let package = try? File(url: vm.pathUrl) {
|
|
_package = package
|
|
}
|
|
}
|
|
|
|
required init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
_name = try container.decode(String.self, forKey: .name)
|
|
_package = try container.decode(File.self, forKey: .package)
|
|
uuid = try container.decode(UUID.self, forKey: .uuid)
|
|
_isSuspended = try container.decode(Bool.self, forKey: .isSuspended)
|
|
_externalDrives = (try container.decode([String: File].self, forKey: .externalDrives)).filter({ $0.value.isValid })
|
|
_sharedDirectories = try container.decode([File].self, forKey: .sharedDirectories).filter({ $0.isValid })
|
|
_windowSettings = try container.decode([Int: Window].self, forKey: .windowSettings)
|
|
_terminalSettings = try container.decodeIfPresent([Int: Terminal].self, forKey: .terminalSettings) ?? [:]
|
|
_hasMigratedConfig = try container.decodeIfPresent(Bool.self, forKey: .hasMigratedConfig) ?? false
|
|
_macRecoveryIpsw = try container.decodeIfPresent(File.self, forKey: .macRecoveryIpsw)
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(_name, forKey: .name)
|
|
try container.encode(_package, forKey: .package)
|
|
try container.encode(uuid, forKey: .uuid)
|
|
try container.encode(_isSuspended, forKey: .isSuspended)
|
|
try container.encode(_externalDrives, forKey: .externalDrives)
|
|
try container.encode(_sharedDirectories, forKey: .sharedDirectories)
|
|
try container.encode(_windowSettings, forKey: .windowSettings)
|
|
try container.encode(_terminalSettings, forKey: .terminalSettings)
|
|
if _hasMigratedConfig {
|
|
try container.encode(_hasMigratedConfig, forKey: .hasMigratedConfig)
|
|
}
|
|
try container.encodeIfPresent(_macRecoveryIpsw, forKey: .macRecoveryIpsw)
|
|
}
|
|
|
|
func asDictionary() throws -> [String: Any] {
|
|
return try propertyList() as! [String: Any]
|
|
}
|
|
|
|
/// Update the UUID
|
|
///
|
|
/// Should only be called from `UTMRegistry`!
|
|
/// - Parameter uuid: UUID to change to
|
|
func _updateUuid(_ uuid: UUID) {
|
|
self.objectWillChange.send()
|
|
self.uuid = uuid
|
|
}
|
|
}
|
|
|
|
protocol UTMRegistryEntryDecodable: Decodable {}
|
|
extension UTMRegistryEntry: UTMRegistryEntryDecodable {}
|
|
|
|
// MARK: - Accessors
|
|
@MainActor extension UTMRegistryEntry {
|
|
var name: String {
|
|
get {
|
|
_name
|
|
}
|
|
|
|
set {
|
|
_name = newValue
|
|
}
|
|
}
|
|
|
|
var package: File {
|
|
get {
|
|
_package
|
|
}
|
|
|
|
set {
|
|
_package = newValue
|
|
}
|
|
}
|
|
|
|
var isSuspended: Bool {
|
|
get {
|
|
_isSuspended
|
|
}
|
|
|
|
set {
|
|
_isSuspended = newValue
|
|
}
|
|
}
|
|
|
|
var externalDrives: [String: File] {
|
|
get {
|
|
_externalDrives
|
|
}
|
|
|
|
set {
|
|
_externalDrives = newValue
|
|
}
|
|
}
|
|
|
|
var externalDrivePublisher: Published<[String: File]>.Publisher {
|
|
$_externalDrives
|
|
}
|
|
|
|
var sharedDirectories: [File] {
|
|
get {
|
|
_sharedDirectories
|
|
}
|
|
|
|
set {
|
|
_sharedDirectories = newValue
|
|
}
|
|
}
|
|
|
|
var windowSettings: [Int: Window] {
|
|
get {
|
|
_windowSettings
|
|
}
|
|
|
|
set {
|
|
_windowSettings = newValue
|
|
}
|
|
}
|
|
|
|
var terminalSettings: [Int: Terminal] {
|
|
get {
|
|
_terminalSettings
|
|
}
|
|
|
|
set {
|
|
_terminalSettings = newValue
|
|
}
|
|
}
|
|
|
|
var hasMigratedConfig: Bool {
|
|
get {
|
|
_hasMigratedConfig
|
|
}
|
|
|
|
set {
|
|
_hasMigratedConfig = newValue
|
|
}
|
|
}
|
|
|
|
var macRecoveryIpsw: File? {
|
|
get {
|
|
_macRecoveryIpsw
|
|
}
|
|
|
|
set {
|
|
_macRecoveryIpsw = newValue
|
|
}
|
|
}
|
|
|
|
func setExternalDrive(_ file: File, forId id: String) {
|
|
externalDrives[id] = file
|
|
}
|
|
|
|
func updateExternalDriveRemoteBookmark(_ bookmark: Data, forId id: String) {
|
|
externalDrives[id]?.remoteBookmark = bookmark
|
|
}
|
|
|
|
func removeExternalDrive(forId id: String) {
|
|
externalDrives.removeValue(forKey: id)
|
|
}
|
|
|
|
func setSingleSharedDirectory(_ file: File) {
|
|
sharedDirectories = [file]
|
|
}
|
|
|
|
func updateSingleSharedDirectoryRemoteBookmark(_ bookmark: Data) {
|
|
if !sharedDirectories.isEmpty {
|
|
sharedDirectories[0].remoteBookmark = bookmark
|
|
}
|
|
}
|
|
|
|
func removeAllSharedDirectories() {
|
|
sharedDirectories = []
|
|
}
|
|
|
|
func update(copying other: UTMRegistryEntry) {
|
|
isSuspended = other.isSuspended
|
|
externalDrives = other.externalDrives
|
|
sharedDirectories = other.sharedDirectories
|
|
windowSettings = other.windowSettings
|
|
terminalSettings = other.terminalSettings
|
|
hasMigratedConfig = other.hasMigratedConfig
|
|
}
|
|
|
|
func setIsSuspended(_ isSuspended: Bool) {
|
|
self.isSuspended = isSuspended
|
|
}
|
|
|
|
func setPackageRemoteBookmark(_ remoteBookmark: Data?, path: String? = nil) {
|
|
package.remoteBookmark = remoteBookmark
|
|
if let path = path {
|
|
package.path = path
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Migration from UTMViewState
|
|
|
|
extension UTMRegistryEntry {
|
|
/// Migrate from a view state
|
|
/// - Parameter viewState: View state to migrate
|
|
private func migrate(viewState: UTMLegacyViewState) {
|
|
var primaryWindow = Window()
|
|
if viewState.displayScale != .zero {
|
|
primaryWindow.scale = viewState.displayScale
|
|
}
|
|
if viewState.displayOriginX != .zero || viewState.displayOriginY != .zero {
|
|
primaryWindow.origin = CGPoint(x: viewState.displayOriginX,
|
|
y: viewState.displayOriginY)
|
|
}
|
|
primaryWindow.isKeyboardVisible = viewState.isKeyboardShown
|
|
primaryWindow.isToolbarVisible = viewState.isToolbarShown
|
|
if primaryWindow != Window() {
|
|
_windowSettings[0] = primaryWindow
|
|
}
|
|
_isSuspended = viewState.hasSaveState
|
|
if let sharedDirectoryBookmark = viewState.sharedDirectory, let sharedDirectoryPath = viewState.sharedDirectoryPath {
|
|
if let file = try? File(path: sharedDirectoryPath,
|
|
bookmark: sharedDirectoryBookmark) {
|
|
_sharedDirectories = [file]
|
|
} else {
|
|
logger.error("Failed to migrate shared directory \(sharedDirectoryPath) because bookmark is invalid.")
|
|
}
|
|
}
|
|
if let shortcutBookmark = viewState.shortcutBookmark {
|
|
_package.remoteBookmark = shortcutBookmark
|
|
}
|
|
for drive in viewState.allDrives() {
|
|
if let bookmark = viewState.bookmark(forRemovableDrive: drive), let path = viewState.path(forRemovableDrive: drive) {
|
|
let file = File(dummyFromPath: path, remoteBookmark: bookmark)
|
|
_externalDrives[drive] = file
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Try to migrate from a view.plist or does nothing if it does not exist.
|
|
/// - Parameter viewStateURL: URL to view.plist
|
|
@objc func migrateUnsafe(viewStateURL: URL) {
|
|
let fileManager = FileManager.default
|
|
guard fileManager.fileExists(atPath: viewStateURL.path) else {
|
|
return
|
|
}
|
|
guard let dict = try? NSDictionary(contentsOf: viewStateURL, error: ()) as? [AnyHashable : Any] else {
|
|
logger.error("Failed to parse legacy \(viewStateURL)")
|
|
return
|
|
}
|
|
let viewState = UTMLegacyViewState(dictionary: dict)
|
|
migrate(viewState: viewState)
|
|
try? fileManager.removeItem(at: viewStateURL) // delete view.plist
|
|
}
|
|
|
|
#if os(macOS)
|
|
/// Try to migrate bookmarks from an Apple VM config.
|
|
/// - Parameter config: Apple config to migrate
|
|
@MainActor func migrate(fromAppleConfig config: UTMAppleConfiguration) {
|
|
for sharedDirectory in config.sharedDirectories {
|
|
if let url = sharedDirectory.directoryURL,
|
|
let file = try? File(url: url, isReadOnly: sharedDirectory.isReadOnly) {
|
|
sharedDirectories.append(file)
|
|
} else {
|
|
logger.error("Failed to migrate a shared directory from config.")
|
|
}
|
|
}
|
|
for drive in config.drives {
|
|
if drive.isExternal, let url = drive.imageURL,
|
|
let file = try? File(url: url, isReadOnly: drive.isReadOnly) {
|
|
externalDrives[drive.id] = file
|
|
} else {
|
|
logger.error("Failed to migrate drive \(drive.id) from config.")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
extension UTMRegistryEntry {
|
|
struct File: Codable, Identifiable {
|
|
var url: URL
|
|
|
|
var path: String
|
|
|
|
var bookmark: Data
|
|
|
|
var remoteBookmark: Data?
|
|
|
|
var isReadOnly: Bool
|
|
|
|
let id: UUID = UUID()
|
|
|
|
fileprivate var isValid: Bool
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case path = "Path"
|
|
case bookmark = "Bookmark"
|
|
case remoteBookmark = "BookmarkRemote"
|
|
case isReadOnly = "ReadOnly"
|
|
}
|
|
|
|
init(path: String, bookmark: Data, isReadOnly: Bool = false) throws {
|
|
self.path = path
|
|
self.bookmark = bookmark
|
|
self.isReadOnly = isReadOnly
|
|
self.url = try URL(resolvingPersistentBookmarkData: bookmark)
|
|
self.isValid = true
|
|
}
|
|
|
|
init(url: URL, isReadOnly: Bool = false) throws {
|
|
self.path = url.path
|
|
self.bookmark = try url.persistentBookmarkData(isReadyOnly: isReadOnly)
|
|
self.isReadOnly = isReadOnly
|
|
self.url = url
|
|
self.isValid = true
|
|
}
|
|
|
|
init(dummyFromPath path: String, remoteBookmark: Data = Data()) {
|
|
self.path = path
|
|
self.bookmark = Data()
|
|
self.isReadOnly = false
|
|
self.url = URL(fileURLWithPath: path)
|
|
self.remoteBookmark = remoteBookmark
|
|
self.isValid = true
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
path = try container.decode(String.self, forKey: .path)
|
|
bookmark = try container.decode(Data.self, forKey: .bookmark)
|
|
isReadOnly = try container.decode(Bool.self, forKey: .isReadOnly)
|
|
remoteBookmark = try container.decodeIfPresent(Data.self, forKey: .remoteBookmark)
|
|
url = URL(fileURLWithPath: path)
|
|
if bookmark.isEmpty {
|
|
isValid = true
|
|
} else {
|
|
// we cannot throw because that stops the decode process so we record the error and continue
|
|
do {
|
|
url = try URL(resolvingPersistentBookmarkData: bookmark)
|
|
isValid = true
|
|
} catch {
|
|
isValid = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(path, forKey: .path)
|
|
try container.encode(bookmark, forKey: .bookmark)
|
|
try container.encode(isReadOnly, forKey: .isReadOnly)
|
|
try container.encodeIfPresent(remoteBookmark, forKey: .remoteBookmark)
|
|
}
|
|
}
|
|
|
|
struct Window: Codable, Equatable {
|
|
var scale: CGFloat = 1.0
|
|
|
|
var origin: CGPoint = .zero
|
|
|
|
var isToolbarVisible: Bool = true
|
|
|
|
var isKeyboardVisible: Bool = false
|
|
|
|
var isDisplayZoomLocked: Bool = true
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case scale = "Scale"
|
|
case origin = "Origin"
|
|
case isToolbarVisible = "ToolbarVisible"
|
|
case isKeyboardVisible = "KeyboardVisible"
|
|
case isDisplayZoomLocked = "DisplayZoomLocked"
|
|
}
|
|
|
|
init() {
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
scale = try container.decode(CGFloat.self, forKey: .scale)
|
|
origin = try container.decode(CGPoint.self, forKey: .origin)
|
|
isToolbarVisible = try container.decode(Bool.self, forKey: .isToolbarVisible)
|
|
isKeyboardVisible = try container.decode(Bool.self, forKey: .isKeyboardVisible)
|
|
isDisplayZoomLocked = try container.decode(Bool.self, forKey: .isDisplayZoomLocked)
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(scale, forKey: .scale)
|
|
try container.encode(origin, forKey: .origin)
|
|
try container.encode(isToolbarVisible, forKey: .isToolbarVisible)
|
|
try container.encode(isKeyboardVisible, forKey: .isKeyboardVisible)
|
|
try container.encode(isDisplayZoomLocked, forKey: .isDisplayZoomLocked)
|
|
}
|
|
}
|
|
|
|
struct Terminal: Codable, Equatable {
|
|
var columns: Int
|
|
|
|
var rows: Int
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case columns = "Columns"
|
|
case rows = "Rows"
|
|
}
|
|
|
|
init(columns: Int = 80, rows: Int = 24) {
|
|
self.columns = columns
|
|
self.rows = rows
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
columns = try container.decode(Int.self, forKey: .columns)
|
|
rows = try container.decode(Int.self, forKey: .rows)
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(columns, forKey: .columns)
|
|
try container.encode(rows, forKey: .rows)
|
|
}
|
|
}
|
|
}
|